19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

View file

@ -1 +1 @@
<svg width="70" height="70" viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>mail/static/description/icon</title><defs><path d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z" id="a"/><linearGradient x1="100%" y1="0%" x2="0%" y2="100%" id="c"><stop stop-color="#CD7690" offset="0%"/><stop stop-color="#CA5377" offset="100%"/></linearGradient><path d="M57.706 35.71c0 9.276-10.012 16.777-22.351 16.777-3.749 0-7.287-.694-10.392-1.92-3.127 2.517-6.969 4.057-11.051 4.493a.835.835 0 0 1-.893-.621c-.1-.404.21-.654.513-.952 1.497-1.484 3.313-2.646 4.027-7.63-2.855-2.815-4.555-6.331-4.555-10.146 0-9.267 10.011-16.776 22.35-16.776 12.34 0 22.352 7.509 22.352 16.776zm-33.647-6.534a1 1 0 0 0-1 1v1.353a1 1 0 0 0 1 1h22.588a1 1 0 0 0 1-1v-1.353a1 1 0 0 0-1-1H24.06zm0 8.056a1 1 0 0 0-1 1v1.353a1 1 0 0 0 1 1h22.588a1 1 0 0 0 1-1v-1.353a1 1 0 0 0-1-1H24.06z" id="d"/><path d="M57.706 33.71c0 9.276-10.012 16.777-22.351 16.777-3.749 0-7.287-.694-10.392-1.92-3.127 2.517-6.969 4.057-11.051 4.493a.835.835 0 0 1-.893-.621c-.1-.404.21-.654.513-.952 1.497-1.484 3.313-2.646 4.027-7.63-2.855-2.815-4.555-6.331-4.555-10.146 0-9.267 10.011-16.776 22.35-16.776 12.34 0 22.352 7.509 22.352 16.776zm-33.647-6.534a1 1 0 0 0-1 1v1.353a1 1 0 0 0 1 1h22.588a1 1 0 0 0 1-1v-1.353a1 1 0 0 0-1-1H24.06zm0 8.056a1 1 0 0 0-1 1v1.353a1 1 0 0 0 1 1h22.588a1 1 0 0 0 1-1v-1.353a1 1 0 0 0-1-1H24.06z" id="e"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z" fill="#FFF" fill-opacity=".383"/><path d="M36.847 68.65L4 69c-2 0-4-.146-4-4.098V46.5L17.288 24 54 24.95v17.92L36.847 68.65z" fill="#393939" opacity=".324"/><path d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z" fill="#000" fill-opacity=".383"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M4 25C4 13.402 13.402 4 25 4s21 9.402 21 21-9.402 21-21 21H7.111A3.111 3.111 0 0 1 4 42.889V25Z" fill="#F86126"/><path d="M5.767 45.696C23.95 43.356 38 27.819 38 9c0-.166-.001-.331-.003-.496A20.91 20.91 0 0 0 25 4C13.402 4 4 13.402 4 25v17.889c0 1.237.722 2.305 1.767 2.807Z" fill="#F78613"/></svg>

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 391 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -0,0 +1,81 @@
// idb-keyval.js 3.2.0
// https://github.com/jakearchibald/idb-keyval
// Copyright 2016, Jake Archibald
// Licensed under the Apache License, Version 2.0
var idbKeyval = (function (exports) {
'use strict';
class Store {
constructor(dbName = 'keyval-store', storeName = 'keyval') {
this.storeName = storeName;
this._dbp = new Promise((resolve, reject) => {
const openreq = indexedDB.open(dbName, 1);
openreq.onerror = () => reject(openreq.error);
openreq.onsuccess = () => resolve(openreq.result);
// First time setup: create an empty object store
openreq.onupgradeneeded = () => {
openreq.result.createObjectStore(storeName);
};
});
}
_withIDBStore(type, callback) {
return this._dbp.then(db => new Promise((resolve, reject) => {
const transaction = db.transaction(this.storeName, type);
transaction.oncomplete = () => resolve();
transaction.onabort = transaction.onerror = () => reject(transaction.error);
callback(transaction.objectStore(this.storeName));
}));
}
}
let store;
function getDefaultStore() {
if (!store)
store = new Store();
return store;
}
function get(key, store = getDefaultStore()) {
let req;
return store._withIDBStore('readonly', store => {
req = store.get(key);
}).then(() => req.result);
}
function set(key, value, store = getDefaultStore()) {
return store._withIDBStore('readwrite', store => {
store.put(value, key);
});
}
function del(key, store = getDefaultStore()) {
return store._withIDBStore('readwrite', store => {
store.delete(key);
});
}
function clear(store = getDefaultStore()) {
return store._withIDBStore('readwrite', store => {
store.clear();
});
}
function keys(store = getDefaultStore()) {
const keys = [];
return store._withIDBStore('readonly', store => {
// This would be store.getAllKeys(), but it isn't supported by Edge or Safari.
// And openKeyCursor isn't supported by Safari.
(store.openKeyCursor || store.openCursor).call(store).onsuccess = function () {
if (!this.result)
return;
keys.push(this.result.key);
this.result.continue();
};
}).then(() => keys);
}
exports.Store = Store;
exports.get = get;
exports.set = set;
exports.del = del;
exports.clear = clear;
exports.keys = keys;
return exports;
}({}));

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,282 @@
Name: mediasoup-client
Version: 3.18.3
License: ISC
Private: false
Description: mediasoup client side TypeScript library
Repository: git+https://github.com/versatica/mediasoup-client.git
Homepage: https://mediasoup.org
Contributors:
Iñaki Baz Castillo <ibc@aliax.net> (https://inakibaz.me)
José Luis Millán <jmillan@aliax.net> (https://github.com/jmillan)
License Copyright:
===
ISC License
Copyright © 2015, Iñaki Baz Castillo <ibc@aliax.net>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
Name: debug
Version: 4.4.3
License: MIT
Private: false
Description: Lightweight debugging utility for Node.js and the browser
Repository: git://github.com/debug-js/debug.git
Author: Josh Junon (https://github.com/qix-)
Contributors:
TJ Holowaychuk <tj@vision-media.ca>
Nathan Rajlich <nathan@tootallnate.net> (http://n8.io)
Andrew Rhyne <rhyneandrew@gmail.com>
License Copyright:
===
(The MIT License)
Copyright (c) 2014-2017 TJ Holowaychuk <tj@vision-media.ca>
Copyright (c) 2018-2021 Josh Junon
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the 'Software'), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
Name: ms
Version: 2.1.3
License: MIT
Private: false
Description: Tiny millisecond conversion utility
Repository: undefined
License Copyright:
===
The MIT License (MIT)
Copyright (c) 2020 Vercel, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Name: events
Version: 3.3.0
License: MIT
Private: false
Description: Node's event emitter for all engines.
Repository: git://github.com/Gozala/events.git
Author: Irakli Gozalishvili <rfobic@gmail.com> (http://jeditoolkit.com)
License Copyright:
===
MIT
Copyright Joyent, Inc. and other Node contributors.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
---
Name: h264-profile-level-id
Version: 2.3.2
License: ISC
Private: false
Description: TypeScript utility to process H264 profile-level-id values
Repository: https://github.com/versatica/h264-profile-level-id.git
Author: Iñaki Baz Castillo <ibc@aliax.net> (https://inakibaz.me)
License Copyright:
===
ISC License
Copyright © 2019, Iñaki Baz Castillo <ibc@aliax.net>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
Name: awaitqueue
Version: 3.3.0
License: ISC
Private: false
Description: TypeScript utility to enqueue asynchronous tasks and run them sequentially one after another
Repository: https://github.com/versatica/awaitqueue.git
Author: Iñaki Baz Castillo <ibc@aliax.net> (https://inakibaz.me)
License Copyright:
===
ISC License
Copyright © 2019, Iñaki Baz Castillo <ibc@aliax.net>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
Name: sdp-transform
Version: 3.0.0
License: MIT
Private: false
Description: A simple parser/writer for the Session Description Protocol
Repository: clux/sdp-transform
Author: Eirik Albrigtsen <sszynrae@gmail.com>
License Copyright:
===
(The MIT License)
Copyright (c) 2013 Eirik Albrigtsen
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
Name: fake-mediastreamtrack
Version: 2.2.1
License: ISC
Private: false
Description: Fake W3C MediaStreamTrack implementation
Repository: git+https://github.com/ibc/fake-mediastreamtrack.git
Author: Iñaki Baz Castillo <ibc@aliax.net> (https://inakibaz.me)
License Copyright:
===
ISC License
Copyright © 2020, Iñaki Baz Castillo <ibc@aliax.net>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
Name: @lukeed/uuid
Version: 2.0.1
License: MIT
Private: false
Description: A tiny (230B) and fast UUID (v4) generator for Node and the browser
Repository: undefined
Author: Luke Edwards <luke.edwards05@gmail.com> (https://lukeed.com)
License Copyright:
===
MIT License
Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -12,35 +12,102 @@
# while exim uses a syntax that looks like:
#
# *: |/home/odoo/src/odoo-mail.py
#
# Note python2 was chosen on purpose for backward compatibility with old mail
# servers.
#
import optparse
# Dev Note exit codes should comply with https://www.unix.com/man-page/freebsd/3/sysexits/
# see http://www.postfix.org/aliases.5.html, output may end up in bounce mails
EX_USAGE = 64
EX_NOUSER = 67
EX_NOHOST = 68
EX_UNAVAILABLE = 69
EX_SOFTWARE = 70
EX_TEMPFAIL = 75
EX_NOPERM = 77
EX_CONFIG = 78
import sys
import traceback
import xmlrpclib
try:
import traceback
try:
import xmlrpclib
except ImportError:
import xmlrpc.client as xmlrpclib
import socket
from optparse import OptionParser as _OptionParser
except ImportError as e:
sys.stderr.write('%s\n' % e)
sys.exit(EX_SOFTWARE)
class OptionParser(_OptionParser):
def exit(self, status=0, msg=None):
if msg:
sys.stderr.write(msg)
sys.stderr.write(" optparse status: %s\n" % status)
sys.exit(EX_USAGE)
def postfix_exit(exit_code=EX_SOFTWARE, message=None, debug=False):
try:
if debug:
traceback.print_exc(None, sys.stderr)
if message:
sys.stderr.write(message)
except Exception:
pass # error handling failed, exit
finally:
sys.exit(exit_code)
def main():
op = optparse.OptionParser(usage='usage: %prog [options]', version='%prog v1.2')
op = OptionParser(usage='usage: %prog [options]', version='%prog v1.3')
op.add_option("-d", "--database", dest="database", help="Odoo database name (default: %default)", default='odoo')
op.add_option("-u", "--userid", dest="userid", help="Odoo user id to connect with (default: %default)", default=1, type=int)
op.add_option("-p", "--password", dest="password", help="Odoo user password (default: %default)", default='admin')
op.add_option("--host", dest="host", help="Odoo host (default: %default)", default='localhost')
op.add_option("--port", dest="port", help="Odoo port (default: %default)", default=8069, type=int)
op.add_option("--proto", dest="protocol", help="Protocol to use (default: %default), http or https", default='http')
op.add_option("--debug", dest="debug", action="store_true", help="Enable debug (may lead to stack traces in bounce mails)", default=False)
op.add_option("--retry-status", dest="retry", action="store_true", help="Send temporary failure status code on connection errors.", default=False)
(o, args) = op.parse_args()
if args:
op.print_help()
sys.stderr.write("unknown arguments: %s\n" % args)
sys.exit(EX_USAGE)
if o.protocol not in ['http', 'https']:
op.print_help()
sys.stderr.write("unknown protocol: %s\n" % o.protocol)
sys.exit(EX_USAGE)
try:
msg = sys.stdin.read()
models = xmlrpclib.ServerProxy('http://%s:%s/xmlrpc/2/object' % (o.host, o.port), allow_none=True)
if sys.version_info > (3,):
msg = msg.encode()
models = xmlrpclib.ServerProxy('%s://%s:%s/xmlrpc/2/object' % (o.protocol, o.host, o.port), allow_none=True)
models.execute_kw(o.database, o.userid, o.password, 'mail.thread', 'message_process', [False, xmlrpclib.Binary(msg)], {})
except xmlrpclib.Fault as e:
# reformat xmlrpc faults to print a readable traceback
err = "xmlrpclib.Fault: %s\n%s" % (e.faultCode, e.faultString)
sys.exit(err)
except Exception as e:
traceback.print_exc(None, sys.stderr)
sys.exit(2)
if e.faultString == 'Access denied' and e.faultCode == 3:
postfix_exit(EX_NOPERM, debug=False)
elif 'database' in e.faultString and 'does not exist' in e.faultString and e.faultCode == 1:
postfix_exit(EX_CONFIG, "database does not exist: %s\n" % o.database, o.debug)
elif 'No possible route' in e.faultString and e.faultCode == 1:
postfix_exit(EX_NOUSER, "alias does not exist in odoo\n", o.debug)
else:
postfix_exit(EX_SOFTWARE, "xmlrpclib.Fault\n", o.debug)
except (socket.error, socket.gaierror) as e:
postfix_exit(
exit_code=EX_TEMPFAIL if o.retry else EX_NOHOST,
message="connection error: %s: %s (%s)\n" % (e.__class__.__name__, e, o.host),
debug=o.debug,
)
except Exception:
postfix_exit(EX_SOFTWARE, "", o.debug)
if __name__ == '__main__':
main()
try:
if __name__ == '__main__':
main()
except Exception:
# Handle all unhandled exceptions to prevent postfix from sending
# a bounce mail that includes the invoked command with args which
# may include the password for the odoo user.
postfix_exit(EX_SOFTWARE, "", True)

View file

@ -1,26 +0,0 @@
/** @odoo-module **/
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class ActivityButtonView extends Component {
setup() {
super.setup();
useRefToModel({ fieldName: 'buttonRef', refName: 'button' });
}
get activityButtonView() {
return this.props.record;
}
}
Object.assign(ActivityButtonView, {
props: { record: Object },
template: 'mail.ActivityButtonView',
});
registerMessagingComponent(ActivityButtonView);

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ActivityButtonView" owl="1">
<a class="o_ActivityButtonView" role="button" t-on-click.prevent="activityButtonView.onClick" t-ref="button">
<i class="o_ActivityButtonView_icon fa fa-fw fa-lg" t-att-class="activityButtonView.buttonClass" role="img"/>
</a>
</t>
</templates>

View file

@ -1,28 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
const { Component } = owl;
class ActivityException extends Component {
get textClass() {
if (this.props.value) {
return 'text-' + this.props.value + ' fa ' + this.props.record.data.activity_exception_icon;
}
return undefined;
}
}
Object.assign(ActivityException, {
props: standardFieldProps,
template: 'mail.ActivityException',
fieldDependencies: {
activity_exception_icon: { type: 'char' },
},
noLabel: true,
});
registry.category('fields').add('activity_exception', ActivityException);

View file

@ -1,20 +0,0 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class ActivityListView extends Component {
get activityListView() {
return this.props.record;
}
}
Object.assign(ActivityListView, {
props: { record: Object },
template: 'mail.ActivityListView',
});
registerMessagingComponent(ActivityListView);

View file

@ -1,8 +0,0 @@
.o_ActivityListView {
width: #{"min(95vw, 300px)"};
max-height: #{"min(95vh, 350px)"};
}
.o_ActivityListView_activityList {
overflow-y: auto;
}

View file

@ -1,45 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ActivityListView" owl="1">
<div class="o_ActivityListView d-flex flex-column" t-ref="root">
<div class="o_ActivityListView_activityList d-flex flex-column flex-grow-1">
<t t-if="activityListView.activityListViewItems.length === 0">
<span class="p-3 text-center fst-italic text-500 border-bottom">Schedule activities to help you get things done.</span>
</t>
<t t-if="activityListView.overdueActivityListViewItems.length > 0">
<div class="d-flex bg-100 py-2 border-bottom">
<span class="text-danger fw-bold mx-3">Overdue</span>
<span class="flex-grow-1"/>
<span class="badge rounded-pill text-bg-danger mx-3 align-self-center" t-esc="activityListView.overdueActivityListViewItems.length"/>
</div>
<t t-foreach="activityListView.overdueActivityListViewItems" t-as="activityListViewItem" t-key="activityListViewItem">
<ActivityListViewItem record="activityListViewItem"/>
</t>
</t>
<t t-if="activityListView.todayActivityListViewItems.length > 0">
<div class="d-flex bg-100 py-2 border-bottom">
<span class="text-warning fw-bold mx-3">Today</span>
<span class="flex-grow-1"/>
<span class="badge rounded-pill text-bg-warning mx-3 align-self-center" t-esc="activityListView.todayActivityListViewItems.length"/>
</div>
<t t-foreach="activityListView.todayActivityListViewItems" t-as="activityListViewItem" t-key="activityListViewItem">
<ActivityListViewItem record="activityListViewItem"/>
</t>
</t>
<t t-if="activityListView.plannedActivityListViewItems.length > 0">
<div class="d-flex bg-100 py-2 border-bottom">
<span class="text-success fw-bold mx-3">Planned</span>
<span class="flex-grow-1"/>
<span class="badge rounded-pill text-bg-success mx-3 align-self-center" t-esc="activityListView.plannedActivityListViewItems.length"/>
</div>
<t t-foreach="activityListView.plannedActivityListViewItems" t-as="activityListViewItem" t-key="activityListViewItem">
<ActivityListViewItem record="activityListViewItem"/>
</t>
</t>
</div>
<button class="o_ActivityListView_addActivityButton btn btn-secondary p-3 text-center" t-on-click="activityListView.onClickAddActivityButton">
<i class="fa fa-plus fa-fw"></i><strong>Schedule an activity</strong>
</button>
</div>
</t>
</templates>

View file

@ -1,20 +0,0 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class ActivityListViewItem extends Component {
get activityListViewItem() {
return this.props.record;
}
}
Object.assign(ActivityListViewItem, {
props: { record: Object },
template: 'mail.ActivityListViewItem',
});
registerMessagingComponent(ActivityListViewItem);

View file

@ -1,16 +0,0 @@
.o_ActivityListViewItem_actionLink {
@include o-hover-text-color($text-muted, map-get($theme-colors, 'success'));
@include o-hover-opacity(0.5, 1);
}
.o_ActivityListViewItem_editButton {
opacity: 0.5;
}
.o_ActivityListViewItem:hover .o_ActivityListViewItem_editButton {
opacity: 1;
}
.o_ActivityListViewItem_container {
min-width: 0;
}

View file

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ActivityListViewItem" owl="1">
<div class="o_ActivityListViewItem d-flex flex-column border-bottom py-2">
<div class="o_ActivityListViewItem_container d-flex align-items-baseline ms-3 me-1">
<i t-if="activityListViewItem.activity.icon" class="fa small me-2" t-attf-class="{{ activityListViewItem.activity.icon }}" role="img"/>
<t t-if="activityListViewItem.activity.summary">
<b class="text-900 me-2 text-truncate flex-grow-1 flex-basis-0" t-esc="activityListViewItem.activity.summary"/>
</t>
<t t-if="!activityListViewItem.activity.summary and activityListViewItem.activity.type">
<b class="text-900 me-2 text-truncate flex-grow-1" t-esc="activityListViewItem.activity.type.displayName"/>
</t>
<button t-if="activityListViewItem.hasEditButton" class="o_ActivityListViewItem_editButton btn btn-sm btn-link" t-on-click="activityListViewItem.onClickEditActivityButton">
<i class="fa fa-pencil"/>
</button>
<t t-if="activityListViewItem.activity.canWrite">
<button t-if="activityListViewItem.fileUploader" class="o_ActivityListViewItem_actionLink btn btn-link shadow-none fs-4 fa fa-upload" title="Upload file" aria-label="Upload File" t-on-click="activityListViewItem.onClickUploadDocument"/>
<button t-if="activityListViewItem.hasMarkDoneButton" class="o_ActivityListViewItem_actionLink o_ActivityListViewItem_markAsDone btn btn-link shadow-none fs-4 fa fa-check-circle" title="Mark as done" aria-label="Mark as done" t-on-click="activityListViewItem.onClickMarkAsDone" t-ref="markDoneButton"/>
</t>
</div>
<div t-if="activityListViewItem.activity.state !== 'today'" class="d-flex align-items-baseline flex-wrap mx-3">
<i class="fa fa-clock-o me-2 text-muted" role="img" aria-label="Deadline" title="Deadline"/>
<t t-if="!activityListViewItem.activity.isCurrentPartnerAssignee and activityListViewItem.activity.assignee">
<small class="text-truncate" t-esc="activityListViewItem.activity.assignee.displayName"/>
<small class="mx-1">-</small>
</t>
<small t-att-title="activityListViewItem.activity.dateDeadline" t-esc="activityListViewItem.delayLabel"/>
</div>
<ActivityMarkDonePopoverContent t-if="activityListViewItem.markDoneView" record="activityListViewItem.markDoneView"/>
<div t-if="activityListViewItem.mailTemplateViews.length > 0" class="mx-3 mt-2">
<MailTemplate
t-foreach="activityListViewItem.mailTemplateViews" t-as="mailTemplateView" t-key="mailTemplateView"
record="mailTemplateView"
/>
</div>
</div>
</t>
</templates>

View file

@ -1,20 +0,0 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class KanbanFieldActivityView extends Component {
get kanbanFieldActivityView() {
return this.props.record;
}
}
Object.assign(KanbanFieldActivityView, {
props: { record: Object },
template: 'mail.KanbanFieldActivityView',
});
registerMessagingComponent(KanbanFieldActivityView);

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.KanbanFieldActivityView" owl="1">
<ActivityButtonView record="kanbanFieldActivityView.activityButtonView"/>
</t>
</templates>

View file

@ -1,98 +0,0 @@
/** @odoo-module **/
// ensure components are registered beforehand.
import '@mail/backend_components/kanban_field_activity_view/kanban_field_activity_view';
import { getMessagingComponent } from '@mail/utils/messaging_component';
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
const { Component, onWillDestroy, onWillUpdateProps } = owl;
const getNextId = (function () {
let tmpId = 0;
return () => {
tmpId += 1;
return tmpId;
};
})();
/**
* Container for messaging component KanbanFieldActivityView ensuring messaging
* records are ready before rendering KanbanFieldActivityView component.
*/
export class KanbanFieldActivityViewContainer extends Component {
/**
* @override
*/
setup() {
super.setup();
this.kanbanFieldActivityView = undefined;
this.kanbanFieldActivityViewId = getNextId();
this._insertFromProps(this.props);
onWillUpdateProps(nextProps => this._insertFromProps(nextProps));
onWillDestroy(() => this._deleteRecord());
}
/**
* @private
*/
_deleteRecord() {
if (this.kanbanFieldActivityView) {
if (this.kanbanFieldActivityView.exists()) {
this.kanbanFieldActivityView.delete();
}
this.kanbanFieldActivityView = undefined;
}
}
/**
* @private
*/
async _insertFromProps(props) {
const messaging = await this.env.services.messaging.get();
if (owl.status(this) === "destroyed") {
this._deleteRecord();
return;
}
const kanbanFieldActivityView = messaging.models['KanbanFieldActivityView'].insert({
id: this.kanbanFieldActivityViewId,
thread: {
activities: props.value.records.map(activityData => {
return {
id: activityData.resId,
};
}),
hasActivities: true,
id: props.record.resId,
model: props.record.resModel,
},
webRecord: props.record,
});
if (kanbanFieldActivityView !== this.kanbanFieldActivityView) {
this._deleteRecord();
this.kanbanFieldActivityView = kanbanFieldActivityView;
}
this.render();
}
}
Object.assign(KanbanFieldActivityViewContainer, {
components: { KanbanFieldActivityView: getMessagingComponent('KanbanFieldActivityView') },
fieldDependencies: {
activity_exception_decoration: { type: 'selection' },
activity_exception_icon: { type: 'char' },
activity_state: { type: 'selection' },
activity_summary: { type: 'char' },
activity_type_icon: { type: 'char' },
activity_type_id: { type: 'many2one', relation: 'mail.activity.type' },
},
props: {
...standardFieldProps,
},
template: 'mail.KanbanFieldActivityViewContainer',
});
registry.category('fields').add('kanban_activity', KanbanFieldActivityViewContainer);

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.KanbanFieldActivityViewContainer" owl="1">
<KanbanFieldActivityView t-if="kanbanFieldActivityView" record="kanbanFieldActivityView"/>
</t>
</templates>

View file

@ -1,20 +0,0 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class ListFieldActivityView extends Component {
get listFieldActivityView() {
return this.props.record;
}
}
Object.assign(ListFieldActivityView, {
props: { record: Object },
template: 'mail.ListFieldActivityView',
});
registerMessagingComponent(ListFieldActivityView);

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ListFieldActivityView" owl="1">
<ActivityButtonView record="listFieldActivityView.activityButtonView"/>
<span class="o_ListFieldActivityView_summary" t-out="listFieldActivityView.summaryText"/>
</t>
</templates>

View file

@ -1,98 +0,0 @@
/** @odoo-module **/
// ensure components are registered beforehand.
import '@mail/backend_components/list_field_activity_view/list_field_activity_view';
import { getMessagingComponent } from '@mail/utils/messaging_component';
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
const { Component, onWillDestroy, onWillUpdateProps } = owl;
const getNextId = (function () {
let tmpId = 0;
return () => {
tmpId += 1;
return tmpId;
};
})();
/**
* Container for messaging component ListFieldActivityView ensuring messaging
* records are ready before rendering ListFieldActivityView component.
*/
export class ListFieldActivityViewContainer extends Component {
/**
* @override
*/
setup() {
super.setup();
this.listFieldActivityView = undefined;
this.listFieldActivityViewId = getNextId();
this._insertFromProps(this.props);
onWillUpdateProps(nextProps => this._insertFromProps(nextProps));
onWillDestroy(() => this._deleteRecord());
}
/**
* @private
*/
_deleteRecord() {
if (this.listFieldActivityView) {
if (this.listFieldActivityView.exists()) {
this.listFieldActivityView.delete();
}
this.listFieldActivityView = undefined;
}
}
/**
* @private
*/
async _insertFromProps(props) {
const messaging = await this.env.services.messaging.get();
if (owl.status(this) === "destroyed") {
this._deleteRecord();
return;
}
const listFieldActivityView = messaging.models['ListFieldActivityView'].insert({
id: this.listFieldActivityViewId,
thread: {
activities: props.value.records.map(activityData => {
return {
id: activityData.resId,
};
}),
hasActivities: true,
id: props.record.resId,
model: props.record.resModel,
},
webRecord: props.record,
});
if (listFieldActivityView !== this.listFieldActivityView) {
this._deleteRecord();
this.listFieldActivityView = listFieldActivityView;
}
this.render();
}
}
Object.assign(ListFieldActivityViewContainer, {
components: { ListFieldActivityView: getMessagingComponent('ListFieldActivityView') },
fieldDependencies: {
activity_exception_decoration: { type: 'selection' },
activity_exception_icon: { type: 'char' },
activity_state: { type: 'selection' },
activity_summary: { type: 'char' },
activity_type_icon: { type: 'char' },
activity_type_id: { type: 'many2one', relation: 'mail.activity.type' },
},
props: {
...standardFieldProps,
},
template: 'mail.ListFieldActivityViewContainer',
});
registry.category('fields').add('list_activity', ListFieldActivityViewContainer);

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ListFieldActivityViewContainer" owl="1">
<ListFieldActivityView t-if="listFieldActivityView" record="listFieldActivityView"/>
</t>
</templates>

View file

@ -0,0 +1,16 @@
declare module "models" {
import { ScheduledMessage as ScheduledMessageClass } from "@mail/chatter/web/scheduled_message_model";
export interface ScheduledMessage extends ScheduledMessageClass {}
export interface Store {
"mail.scheduled.message": StaticMailRecord<ScheduledMessage, typeof ScheduledMessageClass>;
}
export interface Thread {
scheduledMessages: ScheduledMessage[];
}
export interface Models {
"mail.scheduled.message": ScheduledMessage;
}
}

View file

@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.Chatter">
<div t-if="state.thread" class="o-mail-Chatter w-100 h-100 flex-grow-1 d-flex flex-column bg-inherit" t-att-class="{ 'overflow-auto o-scrollbar-thin': props.isChatterAside, 'o-chatter-disabled': props.threadId === false }" t-on-scroll="onScrollDebounced" t-ref="root">
<div class="o-mail-Chatter-top d-print-none position-sticky top-0" t-att-class="{ 'shadow-sm': state.isTopStickyPinned }" t-ref="top">
<div class="o-mail-Chatter-topbar d-flex flex-shrink-0 flex-grow-0 overflow-x-auto">
<div t-if="state.isSearchOpen" class="flex-grow-1">
<SearchMessageInput closeSearch.bind="closeSearch" messageSearch="messageSearch" thread="state.thread"/>
</div>
<t t-else="" name="chatter-topbar-left-buttons">
<button class="o-mail-Chatter-sendMessage btn text-nowrap me-1" t-att-class="{
'btn-primary': state.composerType !== 'note',
'btn-secondary': state.composerType === 'note',
'active': state.composerType === 'message',
'my-2': !props.compactHeight
}" t-att-disabled="!state.thread.canPostMessage and props.threadId" data-hotkey="m" t-on-click="() => this.toggleComposer('message')">
Send message
</button>
<button class="o-mail-Chatter-logNote btn text-nowrap me-1" t-att-class="{
'btn-primary active': state.composerType === 'note',
'btn-secondary': state.composerType !== 'note',
'my-2': !props.compactHeight
}" t-att-disabled="!state.thread.canPostMessage and props.threadId" data-hotkey="shift+m" t-on-click="() => this.toggleComposer('note')">
Log note
</button>
<button t-if="props.has_activities" class="o-mail-Chatter-activity btn btn-secondary text-nowrap" t-att-class="{ 'my-2': !props.compactHeight }"
t-att-disabled="!state.thread.canPostMessage and props.threadId"
data-hotkey="shift+a" t-on-click="scheduleActivity">
<span>Activity</span>
</button>
<span class="o-mail-Chatter-topbarGrow flex-grow-1 pe-2"/>
<button class="btn btn-link text-action" aria-label="Search Messages" title="Search Messages" t-on-click="onClickSearch">
<i class="oi oi-search" role="img"/>
</button>
</t>
<button t-if="props.hasAttachmentPreview and state.thread.attachmentsInWebClientView.length" class="btn btn-link text-action" t-on-click="popoutAttachment">
<i class="fa fa-window-restore" aria-hidden="Pop out Attachments" title="Pop out Attachments"/>
</button>
<FileUploader t-if="attachments.length === 0" fileUploadClass="'o-mail-Chatter-fileUploader'" multiUpload="true" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
<t t-set-slot="toggler">
<t t-call="mail.Chatter.attachFiles"/>
</t>
</FileUploader>
<t t-else="" t-call="mail.Chatter.attachFiles"/>
<div class="o-mail-Followers d-flex me-1">
<Dropdown position="'bottom-end'" menuClass="'o-mail-Followers-dropdown ' + store.discussDropdownMenuClass(this)" state="followerListDropdown">
<button t-att-class="'o-mail-Followers-button btn btn-link d-flex align-items-center text-action px-1 ' + (props.compactHeight ? '' : 'my-2') + ' ' + (state.thread.selfFollower ? 'active' : '')" t-att-disabled="isDisabled" t-att-title="followerButtonLabel">
<i class="fa fa-fw" role="img" t-attf-class="{{ state.thread.selfFollower ? 'fa-user' : 'fa-user-o' }}"/>
<i t-if="state.thread.id and state.thread.followersCount === undefined" class="fa fa-circle-o-notch fa-spin"/>
<sup t-else="" class="o-mail-Followers-counter" t-esc="state.thread.followersCount ?? 0"/>
</button>
<t t-set-slot="content">
<FollowerList onAddFollowers.bind="onAddFollowers" onFollowerChanged.bind="onFollowerChanged" thread="state.thread" dropdown="followerListDropdown"/>
</t>
</Dropdown>
</div>
</div>
<t t-if="state.composerType">
<t t-if="state.composerType === 'message'">
<div class="d-flex py-1 ps-3">
<span class="flex-shrink-0 text-end fw-bold px-2" style="width:46px">To: </span>
<RecipientsInput thread="state.thread"/>
</div>
</t>
<Composer composer="state.thread.composer" autofocus="true" className="state.composerType === 'message' ? '' : 'pt-4'" mode="'extended'" onPostCallback.bind="onPostCallback" onCloseFullComposerCallback.bind="onCloseFullComposerCallback" dropzoneRef="rootRef" type="state.composerType" t-key="props.threadId"/>
</t>
</div>
<div class="o-mail-Chatter-content d-flex flex-column flex-grow-1 bg-inherit">
<t>
<t t-if="props.has_activities and activities.length and !messageSearch.searching and !messageSearch.searched">
<t t-call="mail.ActivityList"/>
</t>
<t t-if="scheduledMessages.length">
<t t-call="mail.ScheduledMessagesList"/>
</t>
<div t-if="state.isAttachmentBoxOpened" class="o-mail-AttachmentBox position-relative">
<div class="d-flex align-items-center">
<hr class="flex-grow-1"/>
<span class="p-3 fw-bold">
Files
</span>
<hr class="flex-grow-1"/>
</div>
<div class="d-flex flex-column">
<AttachmentList
attachments="attachments"
unlinkAttachment.bind="unlinkAttachment"
/>
<FileUploader multiUpload="true" fileUploadClass="'o-mail-Chatter-fileUploader'" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
<t t-set-slot="toggler">
<button class="btn btn-link" type="button" t-att-disabled="!state.thread.canPostMessage">
<i class="fa fa-plus-square"/>
Attach files
</button>
</t>
</FileUploader>
</div>
</div>
<SearchMessageResult t-if="messageSearch.searching or messageSearch.searched" thread="state.thread" messageSearch="messageSearch" onClickJump.bind="closeSearch"/>
<Thread t-else="" thread="state.thread" t-key="state.thread.localId" order="'desc'" scrollRef="rootRef" jumpPresent="state.jumpThreadPresent"/>
</t>
</div>
</div>
</t>
<t t-name="mail.ActivityList">
<div class="o-mail-ActivityList">
<div class="d-flex pt-2 px-2 cursor-pointer fw-bolder" t-on-click="toggleActivities">
<hr class="flex-grow-1 fs-3"/>
<div class="d-flex align-items-center px-3">
<i class="fa fa-fw" t-att-class="state.showActivities ? 'fa-caret-down' : 'fa-caret-right'"/>
Planned Activities
<span t-if="!state.showActivities" class="badge rounded-pill ms-2 text-bg-success"><t t-esc="activities.length"/></span>
</div>
<hr class="flex-grow-1 fe-3"/>
</div>
<t t-if="state.showActivities">
<t t-foreach="activities" t-as="activity" t-key="activity.id">
<Activity activity="activity" onActivityChanged.bind="onActivityChanged" reloadParentView.bind="reloadParentView"/>
</t>
</t>
</div>
</t>
<t t-name="mail.ScheduledMessagesList">
<div class="o-mail-ScheduledMessagesList">
<div class="d-flex pt-2 cursor-pointer fw-bolder" t-on-click="toggleScheduledMessages">
<hr class="flex-grow-1 fs-3"/>
<div class="d-flex align-items-center px-3">
<i class="fa fa-fw" t-att-class="state.showScheduledMessages ? 'fa-caret-down' : 'fa-caret-right'"/>
Scheduled Messages
<span t-if="!state.showScheduledMessages" class="badge rounded-pill ms-2 text-bg-success" t-out="scheduledMessages.length"/>
</div>
<hr class="flex-grow-1 fe-3"/>
</div>
<div t-if="state.showScheduledMessages">
<t t-foreach="scheduledMessages" t-as="scheduledMessage" t-key="scheduledMessage.id">
<ScheduledMessage scheduledMessage="scheduledMessage" onScheduledMessageChanged.bind="onScheduledMessageChanged"/>
</t>
</div>
</div>
</t>
<t t-name="mail.Chatter.attachFiles">
<button class="o-mail-Chatter-attachFiles btn btn-link text-action px-1 d-flex align-items-center" aria-label="Attach files" t-att-class="{ 'my-2': !props.compactHeight }" t-on-click="onClickAddAttachments"
t-att-disabled="state.isSearchOpen || (!state.thread.canPostMessage and attachments.length === 0)">
<i class="fa fa-paperclip fa-lg me-1"/>
<sup t-if="attachments.length > 0" t-esc="attachments.length"/>
<i t-if="!state.thread.areAttachmentsLoaded and state.thread.isLoadingAttachments and state.showAttachmentLoading" class="fa fa-circle-o-notch fa-spin" aria-label="Attachment counter loading..."/>
</button>
</t>
</templates>

View file

@ -0,0 +1,478 @@
import { ScheduledMessage } from "@mail/chatter/web/scheduled_message";
import { Activity } from "@mail/core/web/activity";
import { AttachmentList } from "@mail/core/common/attachment_list";
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { FollowerList } from "@mail/core/web/follower_list";
import { assignGetter, isDragSourceExternalFile } from "@mail/utils/common/misc";
import { useAttachmentUploader } from "@mail/core/common/attachment_uploader_hook";
import { useCustomDropzone } from "@web/core/dropzone/dropzone_hook";
import { useHover, useMessageScrolling } from "@mail/utils/common/hooks";
import { MailAttachmentDropzone } from "@mail/core/common/mail_attachment_dropzone";
import { RecipientsInput } from "@mail/core/web/recipients_input";
import { SearchMessageInput } from "@mail/core/common/search_message_input";
import { SearchMessageResult } from "@mail/core/common/search_message_result";
import { KeepLast } from "@web/core/utils/concurrency";
import { status, useEffect } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { browser } from "@web/core/browser/browser";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { FileUploader } from "@web/views/fields/file_handler";
import { patch } from "@web/core/utils/patch";
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
import { useService } from "@web/core/utils/hooks";
import { useMessageSearch } from "@mail/core/common/message_search_hook";
import { usePopoutAttachment } from "@mail/core/common/attachment_view";
import { rpc } from "@web/core/network/rpc";
import { useRecordObserver } from "@web/model/relational_model/utils";
export const DELAY_FOR_SPINNER = 1000;
Object.assign(Chatter.components, {
Activity,
AttachmentList,
Dropdown,
FileUploader,
FollowerList,
RecipientsInput,
ScheduledMessage,
SearchMessageInput,
SearchMessageResult,
});
Chatter.props.push(
"close?",
"compactHeight?",
"has_activities?",
"hasAttachmentPreview?",
"hasParentReloadOnActivityChanged?",
"hasParentReloadOnAttachmentsChanged?",
"hasParentReloadOnFollowersUpdate?",
"hasParentReloadOnMessagePosted?",
"highlightMessageId?",
"isAttachmentBoxVisibleInitially?",
"isChatterAside?",
"isInFormSheetBg?",
"saveRecord?",
"record?"
);
Object.assign(Chatter.defaultProps, {
compactHeight: false,
has_activities: true,
hasAttachmentPreview: false,
hasParentReloadOnActivityChanged: false,
hasParentReloadOnAttachmentsChanged: false,
hasParentReloadOnFollowersUpdate: false,
hasParentReloadOnMessagePosted: false,
isAttachmentBoxVisibleInitially: false,
isChatterAside: false,
isInFormSheetBg: true,
});
/**
* @type {import("@mail/chatter/web_portal/chatter").Chatter }
* @typedef {Object} Props
* @property {function} [close]
*/
patch(Chatter.prototype, {
setup() {
this.messageHighlight = useMessageScrolling();
super.setup(...arguments);
this.orm = useService("orm");
this.keepLastSuggestedRecipientsUpdate = new KeepLast();
/** @deprecated equivalent to partner_fields and primary_email_field on thread */
this.mailImpactingFields = { recordFields: [], emailFields: [] };
useRecordObserver((record) => this.updateRecipients(record));
this.attachmentPopout = usePopoutAttachment();
Object.assign(this.state, {
composerType: false,
isAttachmentBoxOpened: this.props.isAttachmentBoxVisibleInitially,
isSearchOpen: false,
showActivities: true,
showAttachmentLoading: false,
showScheduledMessages: true,
});
this.messageSearch = useMessageSearch();
this.attachmentUploader = useAttachmentUploader(
this.store.Thread.insert({ model: this.props.threadModel, id: this.props.threadId })
);
this.unfollowHover = useHover("unfollow");
this.followerListDropdown = useDropdownState();
/** @type {number|null} */
this.loadingAttachmentTimeout = null;
useCustomDropzone(
this.rootRef,
MailAttachmentDropzone,
{
extraClass: "o-mail-Chatter-dropzone",
/** @param {Event} ev */
onDrop: async (ev) => {
if (this.state.composerType) {
return;
}
if (isDragSourceExternalFile(ev.dataTransfer)) {
const files = [...ev.dataTransfer.files];
if (!this.state.thread.id) {
const saved = await this.props.saveRecord?.();
if (!saved) {
return;
}
}
Promise.all(
files.map((file) => this.attachmentUploader.uploadFile(file))
).then(() => {
if (this.props.hasParentReloadOnAttachmentsChanged) {
this.reloadParentView();
}
});
this.state.isAttachmentBoxOpened = true;
}
},
},
() => !this.store.meetingViewOpened || this.env.inMeetingView
);
useEffect(
() => {
if (!this.state.thread) {
return;
}
browser.clearTimeout(this.loadingAttachmentTimeout);
if (this.state.thread?.isLoadingAttachments) {
this.loadingAttachmentTimeout = browser.setTimeout(
() => (this.state.showAttachmentLoading = true),
DELAY_FOR_SPINNER
);
} else {
this.state.showAttachmentLoading = false;
this.state.isAttachmentBoxOpened =
this.state.isAttachmentBoxOpened ||
(this.props.isAttachmentBoxVisibleInitially && this.attachments.length > 0);
}
return () => browser.clearTimeout(this.loadingAttachmentTimeout);
},
() => [this.state.thread, this.state.thread?.isLoadingAttachments]
);
useEffect(
() => {
if (
this.state.thread &&
!["new", "loading"].includes(this.state.thread.status) &&
this.attachments.length === 0
) {
this.state.isAttachmentBoxOpened = false;
}
},
() => [this.state.thread?.status, this.attachments]
);
useEffect(
() => {
this.state.aside = this.props.isChatterAside;
},
() => [this.props.isChatterAside]
);
},
async updateRecipients(record, mode = this.state.composerType) {
if (!record) {
return;
}
// Hack: Make the useRecordObserver subscribe to the record changes
Object.keys(record.data).forEach((field) => record.data[field]);
const partnerIds = []; // Ensure that we don't have duplicates
let email;
this.mailImpactingFields.recordFields.forEach((field) => {
const value = record._changes[field];
if (record.data[field] !== undefined && value) {
partnerIds.push(value.id);
}
});
this.mailImpactingFields.emailFields.forEach((field) => {
const value = record._changes[field];
if (record.data[field] !== undefined && value) {
email = value;
return;
}
});
if ((!partnerIds.length && !email) || mode !== "message" || status(this) === "destroyed") {
return;
}
const recipients = await this.keepLastSuggestedRecipientsUpdate.add(
rpc("/mail/thread/recipients/get_suggested_recipients", {
thread_model: this.props.threadModel,
thread_id: this.props.threadId,
partner_ids: partnerIds,
main_email: email,
})
);
if (status(this) === "destroyed" && !this.state.thread) {
return;
}
this.state.thread.suggestedRecipients = recipients.map((result) => ({
display_name: result.display_name,
email: result.email,
partner_id: result.partner_id,
name: result.name || result.email,
}));
this.state.thread.additionalRecipients = this.state.thread.additionalRecipients.filter(
(additionalRecipient) =>
this.state.thread.suggestedRecipients.every(
(suggestedRecipient) =>
suggestedRecipient.partner_id !== additionalRecipient.partner_id
)
);
},
/**
* @returns {import("models").Activity[]}
*/
get activities() {
return this.state.thread?.activities ?? [];
},
get afterPostRequestList() {
return [
...super.afterPostRequestList,
"followers",
"scheduledMessages",
"suggestedRecipients",
];
},
get attachments() {
return this.state.thread?.attachments ?? [];
},
get childSubEnv() {
const res = Object.assign(super.childSubEnv, { messageHighlight: this.messageHighlight });
assignGetter(res.inChatter, { aside: () => this.props.isChatterAside });
Object.assign(res.inChatter, { toggleComposer: this.toggleComposer.bind(this) });
return res;
},
get followerButtonLabel() {
return _t("Show Followers");
},
get followingText() {
return _t("Following");
},
/**
* @returns {boolean}
*/
get isDisabled() {
return !this.state.thread.id || !this.state.thread?.hasReadAccess;
},
get onCloseFullComposerRequestList() {
return [...super.onCloseFullComposerRequestList, "scheduledMessages"];
},
get requestList() {
return [
...super.requestList,
"activities",
"attachments",
"contact_fields",
"followers",
"scheduledMessages",
"suggestedRecipients",
];
},
get scheduledMessages() {
return this.state.thread?.scheduledMessages ?? [];
},
get unfollowText() {
return _t("Unfollow");
},
changeThread(threadModel, threadId) {
super.changeThread(...arguments);
this.attachmentUploader.thread = this.state.thread;
if (threadId === false) {
this.state.composerType = false;
} else {
this.onThreadCreated?.(this.state.thread);
this.onThreadCreated = null;
this.messageSearch.thread = this.state.thread;
this.closeSearch();
}
},
closeSearch() {
this.messageSearch.clear();
this.state.isSearchOpen = false;
},
/** @override */
async load(thread, requestList) {
await super.load(...arguments);
if (!thread.id || !this.state.thread?.eq(thread)) {
return;
}
this.mailImpactingFields = {
emailFields: this.state.thread.primary_email_field
? [this.state.thread.primary_email_field]
: [],
recordFields: this.state.thread.partner_fields || [],
};
this.updateRecipients(this.props.record);
},
onActivityChanged(thread) {
this.load(thread, [...this.requestList, "messages"]);
if (this.props.hasParentReloadOnActivityChanged) {
this.reloadParentView();
}
},
onAddFollowers() {
this.load(this.state.thread, ["followers", "suggestedRecipients"]);
if (this.props.hasParentReloadOnFollowersUpdate) {
this.reloadParentView();
}
},
onClickAddAttachments() {
if (this.attachments.length === 0) {
return;
}
this.state.isAttachmentBoxOpened = !this.state.isAttachmentBoxOpened;
if (this.state.isAttachmentBoxOpened) {
this.rootRef.el.scrollTop = 0;
this.state.thread.scrollTop = "bottom";
}
},
async onClickAttachFile(ev) {
if (this.state.thread.id) {
return;
}
const saved = await this.props.saveRecord?.();
if (!saved) {
return false;
}
},
onClickSearch() {
this.state.composerType = false;
this.state.isSearchOpen = !this.state.isSearchOpen;
},
onCloseFullComposerCallback(isDiscard) {
this.toggleComposer();
super.onCloseFullComposerCallback();
if (!isDiscard) {
this.reloadParentView();
}
},
onFollowerChanged() {
document.body.click(); // hack to close dropdown
this.reloadParentView();
},
_onMounted() {
super._onMounted();
if (this.state.thread && this.props.highlightMessageId) {
this.state.thread.highlightMessage = this.props.highlightMessageId;
}
},
onPostCallback() {
if (this.props.hasParentReloadOnMessagePosted) {
this.reloadParentView();
}
this.toggleComposer();
super.onPostCallback();
},
onScheduledMessageChanged(thread) {
// reload messages as well as a scheduled message could have been sent
this.load(thread, ["scheduledMessages", "messages"]);
// sending a message could trigger another action (eg. move so to quotation sent)
this.reloadParentView();
},
onSuggestedRecipientAdded(thread) {
this.load(thread, ["suggestedRecipients"]);
},
async onUploaded(data) {
await this.attachmentUploader.uploadData(data);
if (this.props.hasParentReloadOnAttachmentsChanged) {
this.reloadParentView();
}
this.state.isAttachmentBoxOpened = true;
if (this.rootRef.el) {
this.rootRef.el.scrollTop = 0;
}
this.state.thread.scrollTop = "bottom";
},
async reloadParentView() {
await this.props.saveRecord?.();
if (this.props.record) {
await this.props.record.load();
}
},
async scheduleActivity() {
this.closeSearch();
const schedule = async (thread) => {
await this.store.scheduleActivity(thread.model, [thread.id]);
this.load(thread, ["activities", "messages"]);
if (this.props.hasParentReloadOnActivityChanged) {
await this.reloadParentView();
}
};
if (this.state.thread.id) {
schedule(this.state.thread);
} else {
this.onThreadCreated = schedule;
this.props.saveRecord?.();
}
},
toggleActivities() {
this.state.showActivities = !this.state.showActivities;
},
toggleComposer(mode = false, { force = false } = {}) {
this.closeSearch();
const toggle = async () => {
if (!force && this.state.composerType === mode) {
this.state.composerType = false;
} else {
if (mode === "message") {
await this.updateRecipients(this.props.record, mode);
}
this.state.composerType = mode;
}
};
if (this.state.thread.id) {
toggle();
} else {
this.onThreadCreated = toggle;
this.props.saveRecord?.();
}
},
toggleScheduledMessages() {
this.state.showScheduledMessages = !this.state.showScheduledMessages;
},
async unlinkAttachment(attachment) {
await this.attachmentUploader.unlink(attachment);
if (this.props.hasParentReloadOnAttachmentsChanged) {
this.reloadParentView();
}
},
popoutAttachment() {
this.attachmentPopout.popout();
},
});

View file

@ -0,0 +1,10 @@
import { patch } from "@web/core/utils/patch";
import { FormArchParser } from "@web/views/form/form_arch_parser";
patch(FormArchParser.prototype, {
parse(xmlDoc, models, modelName) {
const result = super.parse(...arguments);
result.has_activities = Boolean(models[modelName].has_activities);
return result;
},
});

View file

@ -0,0 +1,116 @@
import { registry } from "@web/core/registry";
import { patch } from "@web/core/utils/patch";
import { append, createElement, extractAttributes, setAttributes } from "@web/core/utils/xml";
import { FormCompiler } from "@web/views/form/form_compiler";
/** @this {FormCompiler} */
function compileChatter(node, params) {
const chatterContainerXml = createElement("t");
setAttributes(chatterContainerXml, {
"t-component": "__comp__.mailComponents.Chatter",
has_activities: "__comp__.props.archInfo.has_activities",
hasAttachmentPreview: Boolean(
this.templates.FormRenderer.querySelector(".o_attachment_preview")
),
hasParentReloadOnActivityChanged: Boolean(node.getAttribute("reload_on_activity")),
hasParentReloadOnAttachmentsChanged: Boolean(node.getAttribute("reload_on_attachment")),
hasParentReloadOnFollowersUpdate: Boolean(node.getAttribute("reload_on_follower")),
hasParentReloadOnMessagePosted: Boolean(node.getAttribute("reload_on_post")),
isAttachmentBoxVisibleInitially: Boolean(node.getAttribute("open_attachments")),
threadId: "__comp__.props.record.resId or undefined",
threadModel: "__comp__.props.record.resModel",
record: "__comp__.props.record",
saveRecord: "() => __comp__.save and __comp__.save()",
highlightMessageId: "__comp__.highlightMessageId",
});
const chatterContainerHookXml = createElement("div");
chatterContainerHookXml.classList.add("o-mail-ChatterContainer", "o-mail-Form-chatter");
setAttributes(chatterContainerHookXml, { "t-if": "!__comp__.env.inDialog" });
append(chatterContainerHookXml, chatterContainerXml);
return chatterContainerHookXml;
}
function compileAttachmentPreview(node, params) {
const webClientViewAttachmentViewContainerHookXml = createElement("div");
webClientViewAttachmentViewContainerHookXml.classList.add("o_attachment_preview");
const webClientViewAttachmentViewContainerXml = createElement("t");
setAttributes(webClientViewAttachmentViewContainerXml, {
"t-component": "__comp__.mailComponents.AttachmentView",
threadId: "__comp__.props.record.resId or undefined",
threadModel: "__comp__.props.record.resModel",
});
append(webClientViewAttachmentViewContainerHookXml, webClientViewAttachmentViewContainerXml);
return webClientViewAttachmentViewContainerHookXml;
}
registry.category("form_compilers").add("chatter_compiler", {
selector: "chatter",
fn: compileChatter,
});
registry.category("form_compilers").add("attachment_preview_compiler", {
selector: "div.o_attachment_preview",
fn: compileAttachmentPreview,
});
patch(FormCompiler.prototype, {
compile(node, params) {
const res = super.compile(node, params);
const chatterContainerHookXml = res.querySelector(".o-mail-Form-chatter");
if (!chatterContainerHookXml) {
return res; // no chatter, keep the result as it is
}
const chatterContainerXml = chatterContainerHookXml.querySelector(
"t[t-component='__comp__.mailComponents.Chatter']"
);
setAttributes(chatterContainerXml, {
isChatterAside: "false",
isInFormSheetBg: "false",
saveRecord: "__comp__.props.saveRecord",
});
if (chatterContainerHookXml.parentNode.classList.contains("o_form_sheet")) {
return res; // if chatter is inside sheet, keep it there
}
const formSheetBgXml = res.querySelector(".o_form_sheet_bg");
const parentXml = formSheetBgXml && formSheetBgXml.parentNode;
if (!parentXml) {
return res; // miss-config: a sheet-bg is required for the rest
}
const webClientViewAttachmentViewHookXml = res.querySelector(".o_attachment_preview");
const hasPreview = !!webClientViewAttachmentViewHookXml;
if (webClientViewAttachmentViewHookXml) {
// in sheet bg (attachment viewer present)
setAttributes(webClientViewAttachmentViewHookXml, {
"t-if": `__comp__.mailLayout(${hasPreview}).includes("COMBO")`,
});
const sheetBgChatterContainerHookXml = chatterContainerHookXml.cloneNode(true);
sheetBgChatterContainerHookXml.classList.add("o-isInFormSheetBg", "w-auto");
setAttributes(sheetBgChatterContainerHookXml, {
"t-if": `__comp__.mailLayout(${hasPreview}) == "COMBO"`,
});
append(formSheetBgXml, sheetBgChatterContainerHookXml);
const sheetBgChatterContainerXml = sheetBgChatterContainerHookXml.querySelector(
"t[t-component='__comp__.mailComponents.Chatter']"
);
setAttributes(sheetBgChatterContainerXml, {
isInFormSheetBg: "true",
isChatterAside: "false",
});
}
// after sheet bg (standard position, either aside or below)
setAttributes(chatterContainerXml, {
isInFormSheetBg: `["COMBO", "BOTTOM_CHATTER"].includes(__comp__.mailLayout(${hasPreview}))`,
isChatterAside: `["SIDE_CHATTER", "EXTERNAL_COMBO_XXL", "EXTERNAL_COMBO"].includes(__comp__.mailLayout(${hasPreview}))`,
});
const { ["t-if"]: tIf } = extractAttributes(chatterContainerHookXml, ["t-if"]);
setAttributes(chatterContainerHookXml, {
"t-if": `${
tIf ? tIf : "true"
} and (!["COMBO", "NONE"].includes(__comp__.mailLayout(${hasPreview})))`, // opposite of sheetBgChatterContainerHookXml
"t-attf-class": `{{ ["SIDE_CHATTER", "EXTERNAL_COMBO_XXL"].includes(__comp__.mailLayout(${hasPreview})) ? "o-aside w-print-100" : "mt-4 mt-md-0" }}`,
});
append(parentXml, chatterContainerHookXml);
return res;
},
});

View file

@ -0,0 +1,56 @@
import { EventBus, useSubEnv } from "@odoo/owl";
import { x2ManyCommands } from "@web/core/orm_service";
import { useService } from "@web/core/utils/hooks";
import { createDocumentFragmentFromContent } from "@web/core/utils/html";
import { patch } from "@web/core/utils/patch";
import { FormController } from "@web/views/form/form_controller";
FormController.props = {
...FormController.props,
fullComposerBus: { type: EventBus, optional: true },
};
patch(FormController.prototype, {
setup() {
super.setup(...arguments);
if (this.env.services["mail.store"]) {
this.mailStore = useService("mail.store");
}
useSubEnv({
chatter: {
fetchThreadData: true,
fetchMessages: true,
},
});
},
onWillLoadRoot(nextConfiguration) {
super.onWillLoadRoot(...arguments);
this.env.chatter.fetchThreadData = true;
this.env.chatter.fetchMessages = true;
const isSameThread =
this.model.root?.resId === nextConfiguration.resId &&
this.model.root?.resModel === nextConfiguration.resModel;
if (isSameThread) {
// not first load
const { resModel, resId } = this.model.root;
this.env.bus.trigger("MAIL:RELOAD-THREAD", { model: resModel, id: resId });
}
},
async onWillSaveRecord(record, changes) {
if (record.resModel === "mail.compose.message") {
const doc = createDocumentFragmentFromContent(changes.body);
const partnerElements = doc.querySelectorAll('[data-oe-model="res.partner"]');
const partnerIds = Array.from(partnerElements).map((element) =>
parseInt(element.dataset.oeId)
);
if (partnerIds.length) {
if (changes.partner_ids[0] && changes.partner_ids[0][0] === x2ManyCommands.SET) {
partnerIds.push(...changes.partner_ids[0][2]);
}
changes.partner_ids.push(...partnerIds.map((pid) => x2ManyCommands.link(pid)));
}
}
},
});

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.FormView" t-inherit-mode="extension">
<xpath expr="//Layout/t[@t-component='props.Renderer']" position="attributes">
<attribute name="saveRecord">() => this.save()</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,71 @@
import { AttachmentView } from "@mail/core/common/attachment_view";
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { onMounted, onWillUnmount, useState } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { router } from "@web/core/browser/router";
import { SIZES } from "@web/core/ui/ui_service";
import { useService } from "@web/core/utils/hooks";
import { patch } from "@web/core/utils/patch";
import { useDebounced } from "@web/core/utils/timing";
import { FormRenderer } from "@web/views/form/form_renderer";
patch(FormRenderer.prototype, {
setup() {
super.setup();
this.mailComponents = {
AttachmentView,
Chatter,
};
this.highlightMessageId = router.current.highlight_message_id;
this.messagingState = useState({
/** @type {import("models").Thread} */
thread: undefined,
});
if (this.env.services["mail.store"]) {
this.mailStore = useService("mail.store");
}
this.uiService = useService("ui");
this.mailPopoutService = useService("mail.popout");
this.onResize = useDebounced(this.render, 200);
onMounted(() => browser.addEventListener("resize", this.onResize));
onWillUnmount(() => browser.removeEventListener("resize", this.onResize));
},
/**
* @returns {boolean}
*/
hasFile() {
if (!this.mailStore || !this.props.record.resId) {
return false;
}
this.messagingState.thread = this.mailStore.Thread.insert({
id: this.props.record.resId,
model: this.props.record.resModel,
});
return this.messagingState.thread.attachmentsInWebClientView.length > 0;
},
mailLayout(hasAttachmentContainer) {
const xxl = this.uiService.size >= SIZES.XXL;
const hasFile = this.hasFile();
const hasChatter = !!this.mailStore;
const hasExternalWindow = !!this.mailPopoutService.externalWindow;
if (hasExternalWindow && hasFile && hasAttachmentContainer) {
if (xxl) {
return "EXTERNAL_COMBO_XXL"; // chatter on the side, attachment in separate tab
}
return "EXTERNAL_COMBO"; // chatter on the bottom, attachment in separate tab
}
if (hasChatter) {
if (xxl) {
if (hasAttachmentContainer && hasFile) {
return "COMBO"; // chatter on the bottom, attachment on the side
}
return "SIDE_CHATTER"; // chatter on the side, no attachment
}
return "BOTTOM_CHATTER"; // chatter on the bottom, no attachment
}
return "NONE";
},
});

View file

@ -0,0 +1,51 @@
// ------------------------------------------------------------------
// Layout
// ------------------------------------------------------------------
.o-mail-ChatterContainer, .o-mail-Form-chatter {
background-color: $o-webclient-background-color;
--Chatter-asideExtraWidth: 0px; // to take into account more items, e.g. "close" chatter feature
&.o-aside {
@media (max-width: 1920px) {
--ChatterAsideForm-padding-left: #{map-get($spacers, 2)};
}
flex-shrink: 0;
flex-grow: 1;
padding: map-get($spacers, 0);
width: calc(#{$o-mail-Chatter-minWidth} + var(--Chatter-asideExtraWidth));
}
&.o-isInFormSheetBg {
margin-left: calc(var(--formView-sheetBg-padding-x) * -1);
margin-right: calc(var(--formView-sheetBg-padding-x) * -1);
}
&:not(.o-aside):not(.o-full-width) .o-mail-Chatter {
margin-left: auto;
margin-right: auto;
}
}
// Reduce horizontal spacing if the next sibling is an aside chatter
.o_form_sheet_bg:has(+ .o-mail-Form-chatter.o-aside) {
@media screen and (max-width: 1920px) {
--formView-sheetBg-padding-right: #{map-get($spacers, 2)};
}
}
// Disable scroll anchoring when chatter is not in aside mode to prevent unwanted layout shifts
.o_content:has(.o-mail-Form-chatter:not(.o-aside)) {
overflow-anchor: none;
}
// ------------------------------------------------------------------
// Style
// ------------------------------------------------------------------
.o-mail-ChatterContainer, .o-mail-Form-chatter {
display: flex;
margin: 0 auto;
padding-bottom: map-get($spacers, 5);
width: 100%;
}

View file

@ -0,0 +1,158 @@
import { formView } from "@web/views/form/form_view";
import { registry } from "@web/core/registry";
import { EventBus, toRaw, useEffect, useRef, useSubEnv } from "@odoo/owl";
import { useCustomDropzone } from "@web/core/dropzone/dropzone_hook";
import { useService } from "@web/core/utils/hooks";
import { useX2ManyCrud } from "@web/views/fields/relational_utils";
import { MailAttachmentDropzone } from "@mail/core/common/mail_attachment_dropzone";
export class MailComposerFormController extends formView.Controller {
static props = {
...formView.Controller.props,
fullComposerBus: { type: EventBus, optional: true },
};
static defaultProps = { fullComposerBus: new EventBus() };
setup() {
super.setup();
toRaw(this.env.dialogData).model = "mail.compose.message";
useSubEnv({
fullComposerBus: this.props.fullComposerBus,
});
}
}
export class MailComposerFormRenderer extends formView.Renderer {
setup() {
super.setup();
this.orm = useService("orm");
// Autofocus the visible editor in edition mode.
this.root = useRef("compiled_view_root");
useEffect(
(isInEdition, el) => {
if (
el &&
isInEdition &&
this.props.record.data.composition_comment_option === "reply_all"
) {
const element = el.querySelector(".note-editable[contenteditable]");
if (element) {
element.focus();
document.dispatchEvent(new Event("selectionchange", {}));
}
}
},
() => [this.props.record.isInEdition, this.root.el, this.props.record.resId]
);
const getActiveMailThreads = () =>
JSON.parse(this.props.record.data.res_ids).map((resId) => {
const thread = this.mailStore.Thread.insert({
model: this.props.record.data.model,
id: resId,
});
return thread;
});
// Add file dropzone on full mail composer:
this.attachmentUploadService = useService("mail.attachment_upload");
this.operations = useX2ManyCrud(() => this.props.record.data["attachment_ids"], true);
useCustomDropzone(this.root, MailAttachmentDropzone, {
/** @param {Event} event */
onDrop: async (event) => {
for (const thread of getActiveMailThreads()) {
for (const file of event.dataTransfer.files) {
const attachment = await this.attachmentUploadService.upload(
thread,
thread.composer,
file
);
await this.operations.saveRecord([attachment.id]);
}
}
},
});
/** @param {function} */
const onCloseWizardModal = (callback) => {
this.env.dialogData.dismiss = callback;
};
onCloseWizardModal(async () => {
const selectedPartnerIds = this.props.record.data.partner_ids.currentIds;
const selectedPartners = await this.orm.searchRead(
"res.partner",
[["id", "in", selectedPartnerIds]],
["email", "id", "lang", "name"]
);
/**
* @param {SuggestedRecipient} recipient
* @returns {SuggestedRecipient}
*/
const updateRecipientWithCorrespondingPartner = (recipient) => {
const partner = selectedPartners.find(
(partner) => partner.id === recipient.id || partner.email === recipient.email
);
if (partner) {
return {
...recipient,
email: partner.email,
lang: partner.lang,
name: partner.name,
partner_id: partner.id,
};
}
return recipient;
};
/**
* @param {SuggestedRecipient} recipient
* @returns {boolean}
*/
const isRecipientSelectedFromFullMailComposer = (recipient) =>
selectedPartnerIds.includes(recipient.partner_id);
for (const thread of getActiveMailThreads()) {
// Update the recipient lists:
thread.suggestedRecipients = thread.suggestedRecipients.map(
updateRecipientWithCorrespondingPartner
);
thread.additionalRecipients = thread.additionalRecipients.map(
updateRecipientWithCorrespondingPartner
);
// Remove the recipients that got removed from the composer:
thread.suggestedRecipients = thread.suggestedRecipients.filter(
isRecipientSelectedFromFullMailComposer
);
thread.additionalRecipients = thread.additionalRecipients.filter(
isRecipientSelectedFromFullMailComposer
);
// Add the recipients that got added to the composer:
for (const partner of selectedPartners) {
const allRecipients = [
...thread.suggestedRecipients,
...thread.additionalRecipients,
];
if (!allRecipients.some((recipient) => recipient.partner_id === partner.id)) {
thread.additionalRecipients.push({
display_name: partner.display_name,
email: partner.email,
lang: partner.lang,
name: partner.name,
partner_id: partner.id,
});
}
}
}
});
}
}
registry.category("views").add("mail_composer_form", {
...formView,
Controller: MailComposerFormController,
Renderer: MailComposerFormRenderer,
});

View file

@ -0,0 +1,30 @@
import { formView } from "@web/views/form/form_view";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class MailComposerSaveTemplateFormController extends formView.Controller {
/** @override */
setup() {
super.setup();
this.actionService = useService("action");
}
/** @override */
async afterExecuteActionButton(clickParams) {
if (clickParams.special !== "cancel") {
return await super.afterExecuteActionButton(...arguments);
}
await this.actionService.doActionButton({
type: "object",
name: "cancel_save_template",
resId: this.model.root.resId,
resModel: this.model.root.resModel,
});
}
}
registry.category("views").add("mail_composer_save_template_form", {
...formView,
Controller: MailComposerSaveTemplateFormController,
});

View file

@ -0,0 +1,95 @@
import { AttachmentList } from "@mail/core/common/attachment_list";
import { RelativeTime } from "@mail/core/common/relative_time";
import { AvatarCardPopover } from "@mail/discuss/web/avatar_card/avatar_card_popover";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
import { Component, useState } from "@odoo/owl";
export const SCHEDULED_MESSAGE_TRUNCATE_THRESHOLD = 50; // arbitrary, ~ 1 line on large screen
export class ScheduledMessage extends Component {
static props = {
onScheduledMessageChanged: Function,
scheduledMessage: Object,
};
static template = "mail.ScheduledMessage";
static components = {
AttachmentList,
RelativeTime,
};
setup() {
super.setup();
this.state = useState({
readMore: false,
});
this.avatarCard = usePopover(AvatarCardPopover);
this.dialogService = useService("dialog");
}
get isShort() {
return (
this.props.scheduledMessage.textContent.length < SCHEDULED_MESSAGE_TRUNCATE_THRESHOLD
);
}
get scheduledDate() {
return this.props.scheduledMessage.scheduled_date.toLocaleString(
luxon.DateTime.DATETIME_SHORT
);
}
get truncatedMessage() {
return (
this.props.scheduledMessage.textContent.substring(
0,
SCHEDULED_MESSAGE_TRUNCATE_THRESHOLD
) + "..."
);
}
async cancel() {
const thread = this.props.scheduledMessage.thread;
await this.props.scheduledMessage.cancel();
this.props.onScheduledMessageChanged(thread);
}
onClick(ev) {
this.props.scheduledMessage.store.handleClickOnLink(ev, this.props.scheduledMessage.thread);
}
async onClickAttachmentUnlink(attachment) {
attachment.remove();
}
onClickAuthor(ev) {
if (!this.avatarCard.isOpen) {
this.avatarCard.open(ev.currentTarget, {
id: this.props.scheduledMessage.author_id.main_user_id?.id,
});
}
}
onClickCancel() {
this.dialogService.add(ConfirmationDialog, {
body: _t("Are you sure you want to cancel the scheduled message?"),
cancel: () => {},
cancelLabel: _t("Close"),
confirm: this.cancel.bind(this),
confirmLabel: _t("Cancel Message"),
});
}
async onClickEdit() {
await this.props.scheduledMessage.edit();
this.props.onScheduledMessageChanged(this.props.scheduledMessage.thread);
}
async onClickSendNow() {
await this.props.scheduledMessage.send();
this.props.onScheduledMessageChanged(this.props.scheduledMessage.thread);
}
}

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.ScheduledMessage">
<div class="o-mail-Scheduled-Message py-1 mb-2">
<div class="o-mail-Message-core position-relative d-flex flex-shrink-0">
<div class="o-mail-Message-sidebar d-flex flex-shrink-0">
<div class="o-mail-Message-avatarContainer position-relative bg-view" t-on-click="ev => this.onClickAuthor(ev)">
<img class="o-mail-Message-avatar w-100 h-100 rounded o_redirect cursor-pointer object-fit-cover" t-att-src="props.scheduledMessage.author_id.avatarUrl"/>
</div>
</div>
<div class="w-100 o-min-width-0">
<div class="o-mail-Message-header d-flex flex-wrap align-items-baseline mb-1 lh-1">
<span class="o-mail-Message-author cursor-pointer o-hover-text-underline" t-on-click="ev => this.onClickAuthor(ev)">
<strong class="me-1 text-truncate o_redirect cursor-pointer" t-out="props.scheduledMessage.author_id.name"/>
</span>
<small class="o-mail-Message-date text-muted opacity-75" t-att-title="scheduledDate">
<i class="fa fa-paper-plane-o mx-1"/>
<RelativeTime datetime="props.scheduledMessage.scheduled_date"/>
</small>
</div>
<div class="position-relative d-flex justify-content-end">
<div class="o-mail-Message-content o-min-width-0 w-100">
<div class="o-mail-Message-textContent position-relative d-flex">
<div class="position-relative overflow-x-auto d-inline-block">
<div class="o-mail-Message-bubble rounded-end-3 rounded-bottom-3 position-absolute top-0 start-0 w-100 h-100"
t-att-class="{
'bg-success-light opacity-25': !props.scheduledMessage.is_note and props.scheduledMessage.isSelfAuthored,
'bg-info-light opacity-25': !props.scheduledMessage.is_note and !props.scheduledMessage.isSelfAuthored
}"/>
<div t-on-click="onClick"
t-attf-class="position-relative text-break o-mail-Message-body #{props.scheduledMessage.is_note ? 'p-1' : 'mb-0 py-2 px-3 align-self-start'}">
<t t-if="props.scheduledMessage.subject and !props.scheduledMessage.isSubjectThreadName">
<em class="mb-1 me-2">Subject: <t t-esc="props.scheduledMessage.subject"/></em>
</t>
<t t-if="isShort or state.readMore" t-out="props.scheduledMessage.body || ''"/>
<p t-else="" t-out="truncatedMessage"/>
<button t-if="!isShort" t-attf-class="btn btn-link #{state.readMore ? '' : 'ps-0'}" t-on-click="() => this.state.readMore = !this.state.readMore">
<t t-if="state.readMore">
Read Less
</t>
<t t-else="">
Read More
</t>
</button>
</div>
</div>
</div>
<AttachmentList
t-if="props.scheduledMessage.attachment_ids.length > 0"
attachments="props.scheduledMessage.attachment_ids.map((a) => a)"
unlinkAttachment.bind="onClickAttachmentUnlink"/>
</div>
</div>
<div class="o-mail-Scheduled-Message-buttons ps-1 d-flex">
<span class="btn btn-link btn-success p-0 me-3" t-if="props.scheduledMessage.editable" t-on-click="onClickSendNow">
<i class="fa fa-send"/> Send Now
</span>
<span class="btn btn-link text-action p-0 me-3" t-if="props.scheduledMessage.editable" t-on-click="onClickEdit">
<i class="fa fa-pencil"/> Edit
</span>
<span class="btn btn-link btn-danger p-0" t-if="props.scheduledMessage.deletable" t-on-click="onClickCancel">
<i class="fa fa-times"/> Cancel
</span>
</div>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,102 @@
import { fields, Record } from "@mail/core/common/record";
import { htmlToTextContentInline } from "@mail/utils/common/format";
import { _t } from "@web/core/l10n/translation";
export class ScheduledMessage extends Record {
static _name = "mail.scheduled.message";
static id = "id";
/** @type {Object.<number, import("models").ScheduledMessage>} */
static records = {};
/** @returns {import("models").ScheduledMessage} */
static get(data) {
return super.get(data);
}
/** @type {number} */
id;
attachment_ids = fields.Many("ir.attachment");
author_id = fields.One("res.partner");
body = fields.Html("");
/** @type {boolean} */
composition_batch;
scheduled_date = fields.Datetime();
/** @type {boolean} */
is_note;
textContent = fields.Attr(false, {
compute() {
if (!this.body) {
return "";
}
return htmlToTextContentInline(this.body);
},
});
thread = fields.One("Thread");
// Editors of the records can delete scheduled messages
get deletable() {
return this.store.self.main_user_id?.is_admin || this.thread.hasWriteAccess;
}
get editable() {
return this.store.self.main_user_id?.is_admin || this.isSelfAuthored;
}
get isSelfAuthored() {
return this.author_id.eq(this.store.self);
}
get isSubjectThreadName() {
return (
this.thread.display_name?.trim().toLowerCase() === this.subject?.trim().toLowerCase()
);
}
/**
* Cancel the scheduled message.
*/
async cancel() {
await this.store.env.services.orm.unlink("mail.scheduled.message", [this.id]);
this.delete();
}
/**
* Open the mail_compose_mesage form view to allow edition of the scheduled message.
* If the message has already been sent, displays a notification instead.
*/
async edit() {
let action;
try {
action = await this.store.env.services.orm.call(
"mail.scheduled.message",
"open_edit_form",
[this.id]
);
} catch {
this.notifyAlreadySent();
return;
}
return new Promise((resolve) =>
this.store.env.services.action.doAction(action, { onClose: resolve })
);
}
notifyAlreadySent() {
this.store.env.services.notification.add(_t("This message has already been sent."), {
type: "warning",
});
}
/**
* Send the scheduled message directly
*/
async send() {
try {
await this.store.env.services.orm.call("mail.scheduled.message", "post_message", [
this.id,
]);
} catch {
// already sent (by someone else or by cron)
return;
}
}
}
ScheduledMessage.register();

View file

@ -0,0 +1,28 @@
import { fields } from "@mail/core/common/record";
import { Thread } from "@mail/core/common/thread_model";
import { compareDatetime } from "@mail/utils/common/misc";
import "@mail/chatter/web_portal/thread_model_patch";
import { patch } from "@web/core/utils/patch";
/** @type {import("models").Thread} */
const threadPatch = {
setup() {
super.setup();
this.scheduledMessages = fields.Many("mail.scheduled.message", {
sort: (a, b) => compareDatetime(a.scheduled_date, b.scheduled_date) || a.id - b.id,
inverse: "thread",
});
},
/** @param {string[]} requestList */
async fetchThreadData(requestList) {
this.isLoadingAttachments =
this.isLoadingAttachments || requestList.includes("attachments");
await super.fetchThreadData(requestList);
if (!this.message_main_attachment_id && this.attachmentsInWebClientView.length > 0) {
this.setMainAttachmentFromIndex(0);
}
},
};
patch(Thread.prototype, threadPatch);

View file

@ -0,0 +1,5 @@
declare module "models" {
export interface Thread {
fetchThreadData: (requestList: string[]) => Promise<void>;
}
}

View file

@ -0,0 +1,127 @@
import { Composer } from "@mail/core/common/composer";
import { Thread } from "@mail/core/common/thread";
import {
Component,
onMounted,
onWillUpdateProps,
useChildSubEnv,
useRef,
useState,
} from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { useThrottleForAnimation } from "@web/core/utils/timing";
/**
* @typedef {Object} Props
* @extends {Component<Props, Env>}
*/
export class Chatter extends Component {
static template = "mail.Chatter";
static components = { Thread, Composer };
static props = ["composer?", "threadId?", "threadModel", "twoColumns?"];
static defaultProps = { composer: true, threadId: false, twoColumns: false };
setup() {
this.store = useService("mail.store");
this.state = useState({
jumpThreadPresent: 0,
/** @type {import("models").Thread} */
thread: undefined,
aside: false,
disabled: !this.props.threadId,
});
this.rootRef = useRef("root");
this.onScrollDebounced = useThrottleForAnimation(this.onScroll);
useChildSubEnv(this.childSubEnv);
onMounted(this._onMounted);
onWillUpdateProps((nextProps) => {
this.state.disabled = !nextProps.threadId;
if (
this.props.threadId !== nextProps.threadId ||
this.props.threadModel !== nextProps.threadModel
) {
this.changeThread(nextProps.threadModel, nextProps.threadId);
}
if (!this.env.chatter || this.env.chatter?.fetchThreadData) {
if (this.env.chatter) {
this.env.chatter.fetchThreadData = false;
}
this.load(this.state.thread, this.requestList);
}
});
}
get afterPostRequestList() {
return ["messages"];
}
get childSubEnv() {
return { inChatter: this.state };
}
get onCloseFullComposerRequestList() {
return ["messages"];
}
get requestList() {
return [];
}
changeThread(threadModel, threadId) {
this.state.thread = this.store.Thread.insert({ model: threadModel, id: threadId });
if (threadId === false) {
if (this.state.thread.messages.length === 0) {
this.state.thread.messages.push({
id: this.store.getNextTemporaryId(),
author_id: this.state.thread.effectiveSelf,
body: _t("Creating a new record..."),
message_type: "notification",
thread: this.state.thread,
trackingValues: [],
res_id: threadId,
model: threadModel,
});
}
}
}
/**
* Fetch data for the thread according to the request list.
* @param {import("models").Thread} thread
* @param {string[]} requestList
*/
async load(thread, requestList) {
if (!thread.id || !this.state.thread?.eq(thread)) {
return;
}
await thread.fetchThreadData(requestList);
}
onCloseFullComposerCallback() {
this.load(this.state.thread, this.onCloseFullComposerRequestList);
}
_onMounted() {
this.changeThread(this.props.threadModel, this.props.threadId);
if (!this.env.chatter || this.env.chatter?.fetchThreadData) {
if (this.env.chatter) {
this.env.chatter.fetchThreadData = false;
}
this.load(this.state.thread, this.requestList);
}
}
onPostCallback() {
this.state.jumpThreadPresent++;
// Load new messages to fetch potential new messages from other users (useful due to lack of auto-sync in chatter).
this.load(this.state.thread, this.afterPostRequestList);
}
onScroll() {
this.state.isTopStickyPinned = this.rootRef.el.scrollTop !== 0;
}
}

View file

@ -0,0 +1,59 @@
.o-mail-Chatter .o-mail-Message-body [summary~="o_mail_notification"] {
display: none;
}
.o-mail-Chatter-top {
z-index: $o-mail-NavigableList-zIndex - 2;
background-color: $o-webclient-background-color;
&.shadow-sm {
background-image: linear-gradient(90deg, transparent, $o-view-background-color, transparent);
}
}
.o-mail-Chatter-top, .o-mail-Chatter-content {
--Chatter-default-padding-x: #{$o-spacer};
padding-right: var(--Chatter-default-padding-x);
}
.o-mail-Chatter-top .o-mail-Composer {
--Chatter-default-padding-x: #{$o-spacer};
--mail-Chatter-contentPaddingLeft: var(--ChatterAsideForm-padding-left, calc(var(--Chatter-default-padding-x) / 2));
margin-left: calc((var(--mail-Chatter-contentPaddingLeft)) * -1);
}
.o-mail-Chatter-top {
padding-left: var(--ChatterAsideForm-padding-left, var(--Chatter-default-padding-x));
}
.o-mail-Chatter-content {
--mail-Chatter-contentPaddingLeft: var(--ChatterAsideForm-padding-left, var(--Chatter-default-padding-x));
padding-left: calc(var(--mail-Chatter-contentPaddingLeft) - #{($o-mail-Message-sidebarWidth - $o-mail-Avatar-size) / 2});
}
.o-mail-Followers-button:focus {
background-color: $gray-200;
}
.o-mail-Followers-dropdown {
/**
* Note: Min() refers to CSS min() and not SCSS min().
*
* To by-pass SCSS min() shadowing CSS min(), we rely on SCSS being case-sensitive while CSS isn't.
*/
max-width: Min(400px, 95vw);
max-height: Min(500px, 50vh);
}
.o-mail-Follower-avatar {
--Avatar-size: #{$o-mail-Avatar-sizeSmall};
}
.o-mail-Follower:has(.o-mail-Follower-action:hover), .o-mail-FollowerList-unfollow:has(.o-mail-Follower-action:hover) {
background-color: inherit !important;
}
.btn.o-mail-Chatter-follow:hover {
color: $black !important;
}

View file

@ -0,0 +1,16 @@
import { Composer } from "@mail/core/common/composer";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
patch(Composer.prototype, {
get placeholder() {
if (this.thread && this.thread.model !== "discuss.channel" && !this.props.placeholder) {
if (this.props.type === "message") {
return _t("Send a message to followers…");
} else {
return _t("Log an internal note…");
}
}
return super.placeholder;
},
});

View file

@ -0,0 +1,17 @@
import { Thread } from "@mail/core/common/thread_model";
import { patch } from "@web/core/utils/patch";
patch(Thread.prototype, {
/** @param {string[]} requestList */
async fetchThreadData(requestList) {
if (requestList.includes("messages")) {
this.fetchNewMessages();
}
await this.store.fetchStoreData("mail.thread", {
access_params: this.rpcParams,
request_list: requestList,
thread_id: this.id,
thread_model: this.model,
});
},
});

View file

@ -1,26 +0,0 @@
/** @odoo-module **/
import { clear } from '@mail/model/model_field_command';
const { onWillUpdateProps, useComponent } = owl;
/**
* This hook provides support for saving the reference of the component directly
* into the field of a record, and appropriately updates it when necessary
* (props change or destroy).
*
* @param {Object} param0
* @param {string} param0.fieldName Name of the field on the target record.
*/
export function useComponentToModel({ fieldName }) {
const component = useComponent();
component.props.record.update({ [fieldName]: component });
onWillUpdateProps(nextProps => {
const currentRecord = component.props.record;
const nextRecord = nextProps.record;
if (currentRecord.exists() && currentRecord !== nextRecord) {
currentRecord.update({ [fieldName]: clear() });
}
nextRecord.update({ [fieldName]: component });
});
}

View file

@ -1,35 +0,0 @@
/** @odoo-module **/
import { Listener } from '@mail/model/model_listener';
const { onRendered, onWillDestroy, onWillRender, useComponent } = owl;
/**
* This hook provides support for automatically re-rendering when used records
* or fields changed.
*
* Components that use this hook must be instantiated after messaging service is
* started. However there is no restriction on the messaging record (coming from
* the modelManager of the messaging service) being already initialized or even
* created.
*/
export function useModels() {
const component = useComponent();
const listener = new Listener({
isLocking: false, // unfortunately __render has side effects such as children components updating their reference to their corresponding model
name: `useModels() of ${component}`,
onChange: () => component.render(),
});
onWillRender(() => {
component.env.services.messaging.modelManager.startListening(listener);
});
onRendered(() => {
component.env.services.messaging.modelManager.stopListening(listener);
});
onWillDestroy(() => {
component.env.services.messaging.modelManager.removeListener(listener);
});
component.env.services.messaging.modelManager.messagingCreatedPromise.then(() => {
component.render();
});
}

View file

@ -1,28 +0,0 @@
/** @odoo-module **/
import { clear } from '@mail/model/model_field_command';
const { onWillUpdateProps, useComponent, useRef } = owl;
/**
* This hook provides support for saving the result of useRef directly into the
* field of a record, and appropriately updates it when necessary (props change
* or destroy).
*
* @param {Object} param0
* @param {string} param0.fieldName Name of the field on the target record.
* @param {string} param0.refName Name of the t-ref on this component.
*/
export function useRefToModel({ fieldName, refName }) {
const component = useComponent();
const ref = useRef(refName);
component.props.record.update({ [fieldName]: ref });
onWillUpdateProps(nextProps => {
const currentRecord = component.props.record;
const nextRecord = nextProps.record;
if (currentRecord.exists() && currentRecord !== nextRecord) {
currentRecord.update({ [fieldName]: clear() });
}
nextRecord.update({ [fieldName]: ref });
});
}

View file

@ -1,16 +0,0 @@
/** @odoo-module **/
const { useComponent } = owl;
/**
* This hook provides support for dynamic-refs.
*
* @returns {function} returns object whose keys are t-ref values of active refs.
* and values are refs.
*/
export function useRefs() {
const component = useComponent();
return function () {
return component.__owl__.refs || {};
};
}

View file

@ -1,36 +0,0 @@
/** @odoo-module **/
import { Listener } from '@mail/model/model_listener';
const { onMounted, onPatched, onWillDestroy, onWillRender, useComponent } = owl;
/**
* This hooks provides support for accessing the values returned by the given
* selector at the time of the last render. The values will be updated after
* every mount/patch.
*
* @param {function} selector function that will be executed at the time of the
* render and of which the result will be stored for future reference.
* @returns {function} function to call to retrieve the last rendered values.
*/
export function useRenderedValues(selector) {
const component = useComponent();
let renderedValues;
let patchedValues;
const listener = new Listener({
name: `useRenderedValues() of ${component}`,
onChange: () => component.render(),
});
onWillRender(() => {
component.env.services.messaging.modelManager.startListening(listener);
renderedValues = selector();
component.env.services.messaging.modelManager.stopListening(listener);
});
onMounted(onUpdate);
onPatched(onUpdate);
function onUpdate() {
patchedValues = renderedValues;
}
onWillDestroy(() => component.env.services.messaging.modelManager.removeListener(listener));
return () => patchedValues;
}

View file

@ -1,30 +0,0 @@
/** @odoo-module **/
import { Listener } from '@mail/model/model_listener';
const { onMounted, onPatched, onWillDestroy, useComponent } = owl;
/**
* This hook provides support for executing code after update (render or patch).
*
* @param {Object} param0
* @param {function} param0.func the function to execute after the update.
*/
export function useUpdate({ func }) {
const component = useComponent();
const listener = new Listener({
isLocking: false, // unfortunately onUpdate methods often have side effect
name: `useUpdate() of ${component}`,
onChange: () => component.render(),
});
function onUpdate() {
component.env.services.messaging.modelManager.startListening(listener);
func();
component.env.services.messaging.modelManager.stopListening(listener);
}
onMounted(onUpdate);
onPatched(onUpdate);
onWillDestroy(() => {
component.env.services.messaging.modelManager.removeListener(listener);
});
}

View file

@ -1,19 +0,0 @@
/** @odoo-module **/
import { useUpdate } from '@mail/component_hooks/use_update';
const { useComponent } = owl;
/**
* This hook provides support for binding the onMounted/onPatched hooks to the
* method of a target record.
*
* @param {Object} param0
* @param {string} param0.methodName Name of the method on the target record.
*/
export function useUpdateToModel({ methodName }) {
const component = useComponent();
useUpdate({ func: () => {
component.props.record[methodName]();
} });
}

View file

@ -1,36 +0,0 @@
/** @odoo-module **/
import { useComponentToModel } from '@mail/component_hooks/use_component_to_model';
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
import Popover from "web.Popover";
import { LegacyComponent } from "@web/legacy/legacy_component";
export class Activity extends LegacyComponent {
/**
* @override
*/
setup() {
super.setup();
useComponentToModel({ fieldName: 'component' });
useRefToModel({ fieldName: 'markDoneButtonRef', refName: 'markDoneButton', });
}
/**
* @returns {ActivityView}
*/
get activityView() {
return this.props.record;
}
}
Object.assign(Activity, {
props: { record: Object },
template: 'mail.Activity',
components: { Popover },
});
registerMessagingComponent(Activity);

View file

@ -1,48 +0,0 @@
// ------------------------------------------------------------------
// Layout
// ------------------------------------------------------------------
.o_Activity_core {
min-width: 0;
}
.o_Activity_detailsUserAvatar {
object-fit: cover;
height: 18px;
width: 18px;
}
.o_Activity_iconContainer {
@include o-position-absolute($top: auto, $left: auto, $bottom: -16%, $right: -5%);
}
.o_Activity_note p {
margin-bottom: map-get($spacers, 0);
}
.o_Activity_sidebar {
width: $o-mail-thread-avatar-size;
min-width: $o-mail-thread-avatar-size;
height: $o-mail-thread-avatar-size;
}
// From python template
.o_mail_note_title {
margin-top: map-get($spacers, 2);
}
.o_mail_note_title + div p {
margin-bottom: map-get($spacers, 0);
}
// ------------------------------------------------------------------
// Style
// ------------------------------------------------------------------
.o_Activity_detailsButton {
@include o-hover-text-color($default-color: $o-main-color-muted);
}
.o_Activity_iconContainer {
box-shadow: 0 0 0 2px $o-view-background-color;
}

View file

@ -1,145 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.Activity" owl="1">
<t t-if="activityView">
<div class="o_Activity d-flex py-2 px-3" t-attf-class="{{ className }}" t-on-click="activityView.onClickActivity" t-ref="root">
<div class="o_Activity_sidebar me-3">
<div class="o_Activity_user position-relative h-100 w-100">
<t t-if="activityView.activity.assignee">
<img class="o_Activity_userAvatar rounded-circle h-100 w-100 o_object_fit_cover" t-attf-src="/web/image/res.users/{{ activityView.activity.assignee.id }}/avatar_128" t-att-alt="activityView.activity.assignee.nameOrDisplayName"/>
</t>
<div class="o_Activity_iconContainer d-flex align-items-center justify-content-center rounded-circle w-50 h-50"
t-att-class="{
'text-bg-success': activityView.activity.state === 'planned',
'text-bg-warning': activityView.activity.state === 'today',
'text-bg-danger': activityView.activity.state === 'overdue',
}"
>
<i class="o_Activity_icon fa small" t-attf-class="{{ activityView.activity.icon }}"/>
</div>
</div>
</div>
<div class="o_Activity_core">
<div class="o_Activity_info d-flex align-items-baseline">
<div class="o_Activity_dueDateText me-2"
t-att-class="{
'text-danger': activityView.activity.state === 'overdue',
'text-success': activityView.activity.state === 'planned',
'text-warning': activityView.activity.state === 'today',
}"
>
<b t-esc="activityView.delayLabel"/>
</div>
<t t-if="activityView.activity.summary">
<b class="o_Activity_summary text-900 me-2">
<t t-esc="activityView.summary"/>
</b>
</t>
<t t-elif="activityView.activity.type">
<b class="o_Activity_summary o_Activity_type text-900 me-2">
<t t-esc="activityView.activity.type.displayName"/>
</b>
</t>
<t t-if="activityView.activity.assignee">
<div class="o_Activity_userName">
<t t-esc="activityView.assignedUserText"/>
</div>
</t>
<a
href="#"
class="o_Activity_detailsButton btn py-0"
t-att-class="activityView.areDetailsVisible ? 'text-primary' : 'btn-link btn-primary'"
t-att-aria-expanded="activityView.areDetailsVisible ? 'true' : 'false'"
t-on-click="activityView.onClickDetailsButton"
role="button"
>
<i class="fa fa-info-circle" role="img" title="Info" aria-label="Info"/>
</a>
</div>
<t t-if="activityView.areDetailsVisible">
<div class="o_Activity_details">
<div class="d-md-table table table-sm mt-2 mb-3">
<div t-if="activityView.activity.type" class="d-md-table-row mb-3">
<div class="d-md-table-cell fw-bold text-md-end m-0 py-md-1 px-md-4">Activity type</div>
<div class="o_Activity_type d-md-table-cell py-md-1 pe-4">
<t t-esc="activityView.activity.type.displayName"/>
</div>
</div>
<div t-if="activityView.activity.creator" class="d-md-table-row mb-3">
<div class="d-md-table-cell fw-bold text-md-end m-0 py-md-1 px-md-4">Created</div>
<div class="o_Activity_detailsCreation d-md-table-cell py-md-1 pe-4">
<t t-esc="activityView.formattedCreateDatetime"/>, <br t-if="messaging.device.isSmall"/>by
<img class="o_Activity_detailsUserAvatar o_Activity_detailsCreatorAvatar ms-1 me-1 rounded-circle align-text-bottom p-0" t-attf-src="/web/image/res.users/{{ activityView.activity.creator.id }}/avatar_128" t-att-title="activityView.activity.creator.nameOrDisplayName" t-att-alt="activityView.activity.creator.nameOrDisplayName"/>
<b class="o_Activity_detailsCreator">
<t t-esc="activityView.activity.creator.nameOrDisplayName"/>
</b>
</div>
</div>
<div t-if="activityView.activity.assignee" class="d-md-table-row mb-3">
<div class="d-md-table-cell fw-bold text-md-end m-0 py-md-1 px-md-4">Assigned to</div>
<div class="o_Activity_detailsAssignation d-md-table-cell py-md-1 pe-4">
<img class="o_Activity_detailsUserAvatar o_Activity_detailsAssignationUserAvatar me-1 rounded-circle align-text-bottom p-0" t-attf-src="/web/image/res.users/{{ activityView.activity.assignee.id }}/avatar_128" t-att-title="activityView.activity.assignee.nameOrDisplayName" t-att-alt="activityView.activity.assignee.nameOrDisplayName"/>
<b t-esc="activityView.activity.assignee.nameOrDisplayName"/>
</div>
</div>
<div class="d-md-table-row">
<div class="d-md-table-cell fw-bold text-md-end m-0 py-md-1 px-md-4">Due on</div>
<div class="o_Activity_detailsDueDate d-md-table-cell py-md-1 pe-4">
<span class="o_Activity_deadlineDateText"
t-att-class="{
'text-danger': activityView.activity.state === 'overdue',
'text-success': activityView.activity.state === 'planned',
'text-warning': activityView.activity.state === 'today',
}"
>
<t t-esc="activityView.formattedDeadlineDate"/>
</span>
</div>
</div>
</div>
</div>
</t>
<t t-if="activityView.activity.note">
<div class="o_Activity_note">
<t t-out="activityView.activity.noteAsMarkup"/>
</div>
</t>
<t t-if="activityView.mailTemplateViews.length > 0">
<div class="o_Activity_mailTemplates">
<t t-foreach="activityView.mailTemplateViews" t-as="mailTemplateView" t-key="mailTemplateView.localId">
<MailTemplate
className="'o_Activity_mailTemplate'"
record="mailTemplateView"
/>
</t>
</div>
</t>
<t t-if="activityView.activity.canWrite">
<div name="tools" class="o_Activity_tools d-flex">
<button class="o_Activity_toolButton o_Activity_markDoneButton btn btn-link btn-primary pt-0 ps-0" t-att-title="activityView.markDoneText" t-ref="markDoneButton" t-on-click="activityView.onClickMarkDoneButton">
<i class="fa fa-check"/> Mark Done
</button>
<t t-if="activityView.fileUploader">
<button class="o_Activity_toolButton o_Activity_uploadButton btn btn-link btn-primary pt-0 ps-0" t-on-click="activityView.onClickUploadDocument">
<i class="fa fa-upload"/> Upload Document
</button>
</t>
<button class="o_Activity_toolButton o_Activity_editButton btn btn-link btn-primary pt-0" t-on-click="activityView.onClickEdit">
<i class="fa fa-pencil"/> Edit
</button>
<button class="o_Activity_toolButton o_Activity_cancelButton btn btn-link btn-primary pt-0" t-on-click="activityView.onClickCancel" >
<i class="fa fa-times"/> Cancel
</button>
</div>
</t>
</div>
</div>
</t>
</t>
</templates>

View file

@ -1,23 +0,0 @@
/** @odoo-module **/
import { registerMessagingComponent } from '@mail/utils/messaging_component';
const { Component } = owl;
export class ActivityBox extends Component {
/**
* @returns {ActivityBoxView}
*/
get activityBoxView() {
return this.props.record;
}
}
Object.assign(ActivityBox, {
props: { record: Object },
template: 'mail.ActivityBox',
});
registerMessagingComponent(ActivityBox);

Some files were not shown because too many files have changed in this diff Show more