mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 18:12:04 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
303
odoo-bringout-oca-ocb-mail/mail/static/src/models/activity.js
Normal file
303
odoo-bringout-oca-ocb-mail/mail/static/src/models/activity.js
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear, insert } from '@mail/model/model_field_command';
|
||||
|
||||
const { markup } = owl;
|
||||
|
||||
registerModel({
|
||||
name: 'Activity',
|
||||
modelMethods: {
|
||||
/**
|
||||
* @param {Object} data
|
||||
* @return {Object}
|
||||
*/
|
||||
convertData(data) {
|
||||
const data2 = {};
|
||||
if ('activity_category' in data) {
|
||||
data2.category = data.activity_category;
|
||||
}
|
||||
if ('can_write' in data) {
|
||||
data2.canWrite = data.can_write;
|
||||
}
|
||||
if ('create_date' in data) {
|
||||
data2.dateCreate = data.create_date;
|
||||
}
|
||||
if ('date_deadline' in data) {
|
||||
data2.dateDeadline = data.date_deadline;
|
||||
}
|
||||
if ('chaining_type' in data) {
|
||||
data2.chaining_type = data.chaining_type;
|
||||
}
|
||||
if ('icon' in data) {
|
||||
data2.icon = data.icon;
|
||||
}
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
if ('note' in data) {
|
||||
data2.rawNote = data.note;
|
||||
}
|
||||
if ('state' in data) {
|
||||
data2.state = data.state;
|
||||
}
|
||||
if ('summary' in data) {
|
||||
data2.summary = data.summary;
|
||||
}
|
||||
|
||||
// relation
|
||||
if ('activity_type_id' in data) {
|
||||
if (!data.activity_type_id) {
|
||||
data2.type = clear();
|
||||
} else {
|
||||
data2.type = insert({
|
||||
displayName: data.activity_type_id[1],
|
||||
id: data.activity_type_id[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
if ('create_uid' in data) {
|
||||
if (!data.create_uid) {
|
||||
data2.creator = clear();
|
||||
} else {
|
||||
data2.creator = insert({
|
||||
id: data.create_uid[0],
|
||||
display_name: data.create_uid[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
if ('mail_template_ids' in data) {
|
||||
data2.mailTemplates = insert(data.mail_template_ids);
|
||||
}
|
||||
if (data.res_id && data.res_model) {
|
||||
data2.thread = insert({
|
||||
id: data.res_id,
|
||||
model: data.res_model,
|
||||
});
|
||||
}
|
||||
if ('user_id' in data) {
|
||||
if (!data.user_id) {
|
||||
data2.assignee = clear();
|
||||
} else {
|
||||
data2.assignee = insert({
|
||||
id: data.user_id[0],
|
||||
display_name: data.user_id[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
if ('request_partner_id' in data) {
|
||||
if (!data.request_partner_id) {
|
||||
data2.requestingPartner = clear();
|
||||
} else {
|
||||
data2.requestingPartner = insert({
|
||||
id: data.request_partner_id[0],
|
||||
display_name: data.request_partner_id[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data2;
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* Delete the record from database and locally.
|
||||
*/
|
||||
async deleteServerRecord() {
|
||||
await this.messaging.rpc({
|
||||
model: 'mail.activity',
|
||||
method: 'unlink',
|
||||
args: [[this.id]],
|
||||
});
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.delete();
|
||||
},
|
||||
/**
|
||||
* Opens (legacy) form view dialog to edit current activity and updates
|
||||
* the activity when dialog is closed.
|
||||
*
|
||||
* @return {Promise} promise that is fulfilled when the form has been closed
|
||||
*/
|
||||
async edit() {
|
||||
await this.messaging.openActivityForm({ activity: this });
|
||||
if (this.exists()) {
|
||||
this.fetchAndUpdate();
|
||||
}
|
||||
},
|
||||
async fetchAndUpdate() {
|
||||
const [data] = await this.messaging.rpc({
|
||||
model: 'mail.activity',
|
||||
method: 'activity_format',
|
||||
args: [this.id],
|
||||
}, { shadow: true }).catch(e => {
|
||||
const errorName = e.message && e.message.data && e.message.data.name;
|
||||
if ([errorName, e.exceptionName].includes('odoo.exceptions.MissingError')) {
|
||||
return [];
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
let shouldDelete = false;
|
||||
if (data) {
|
||||
this.update(this.constructor.convertData(data));
|
||||
} else {
|
||||
shouldDelete = true;
|
||||
}
|
||||
this.thread.fetchData(['activities', 'attachments', 'messages']);
|
||||
if (shouldDelete) {
|
||||
this.delete();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {Attachment[]} [param0.attachments=[]]
|
||||
* @param {string|boolean} [param0.feedback=false]
|
||||
*/
|
||||
async markAsDone({ attachments = [], feedback = false }) {
|
||||
const attachmentIds = attachments.map(attachment => attachment.id);
|
||||
const thread = this.thread;
|
||||
await this.messaging.rpc({
|
||||
model: 'mail.activity',
|
||||
method: 'action_feedback',
|
||||
args: [[this.id]],
|
||||
kwargs: {
|
||||
attachment_ids: attachmentIds,
|
||||
feedback,
|
||||
},
|
||||
});
|
||||
if (thread.exists()) {
|
||||
thread.fetchData(['attachments', 'messages']);
|
||||
}
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.delete();
|
||||
},
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {string} param0.feedback
|
||||
* @returns {Object}
|
||||
*/
|
||||
async markAsDoneAndScheduleNext({ feedback }) {
|
||||
const thread = this.thread;
|
||||
const action = await this.messaging.rpc({
|
||||
model: 'mail.activity',
|
||||
method: 'action_feedback_schedule_next',
|
||||
args: [[this.id]],
|
||||
kwargs: { feedback },
|
||||
});
|
||||
if (thread.exists()) {
|
||||
thread.fetchData(['activities', 'attachments', 'messages']);
|
||||
}
|
||||
if (this.exists()) {
|
||||
this.delete();
|
||||
}
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => {
|
||||
this.env.services.action.doAction(
|
||||
action,
|
||||
{
|
||||
onClose: resolve,
|
||||
},
|
||||
);
|
||||
});
|
||||
if (!thread.exists()) {
|
||||
return;
|
||||
}
|
||||
thread.fetchData(['activities']);
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activityViews: many('ActivityView', {
|
||||
inverse: 'activity',
|
||||
}),
|
||||
assignee: one('User', {
|
||||
inverse: 'activitiesAsAssignee',
|
||||
}),
|
||||
attachments: many('Attachment', {
|
||||
inverse: 'activities',
|
||||
}),
|
||||
canWrite: attr({
|
||||
default: false,
|
||||
}),
|
||||
category: attr(),
|
||||
creator: one('User'),
|
||||
dateCreate: attr(),
|
||||
dateDeadline: attr(),
|
||||
/**
|
||||
* Backup of the feedback content of an activity to be marked as done in the popover.
|
||||
* Feature-specific to restoring the feedback content when component is re-mounted.
|
||||
* In all other cases, this field value should not be trusted.
|
||||
*/
|
||||
feedbackBackup: attr(),
|
||||
chaining_type: attr({
|
||||
default: 'suggest',
|
||||
}),
|
||||
icon: attr(),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
isCurrentPartnerAssignee: attr({
|
||||
compute() {
|
||||
if (!this.assignee || !this.assignee.partner || !this.messaging.currentPartner) {
|
||||
return false;
|
||||
}
|
||||
return this.assignee.partner === this.messaging.currentPartner;
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
mailTemplates: many('MailTemplate', {
|
||||
inverse: 'activities',
|
||||
}),
|
||||
/**
|
||||
* This value is meant to be returned by the server
|
||||
* (and has been sanitized before stored into db).
|
||||
* Do not use this value in a 't-raw' if the activity has been created
|
||||
* directly from user input and not from server data as it's not escaped.
|
||||
*/
|
||||
note: attr({
|
||||
/**
|
||||
* Wysiwyg editor put `<p><br></p>` even without a note on the activity.
|
||||
* This compute replaces this almost empty value by an actual empty
|
||||
* value, to reduce the size the empty note takes on the UI.
|
||||
*/
|
||||
compute() {
|
||||
if (this.rawNote === '<p><br></p>') {
|
||||
return clear();
|
||||
}
|
||||
return this.rawNote;
|
||||
},
|
||||
}),
|
||||
noteAsMarkup: attr({
|
||||
compute() {
|
||||
return markup(this.note);
|
||||
},
|
||||
}),
|
||||
rawNote: attr(),
|
||||
/**
|
||||
* Determines that an activity is linked to a requesting partner or not.
|
||||
* It will be used notably in website slides to know who triggered the
|
||||
* "request access" activity.
|
||||
* Also, be useful when the assigned user is different from the
|
||||
* "source" or "requesting" partner.
|
||||
*/
|
||||
requestingPartner: one('Partner'),
|
||||
state: attr(),
|
||||
summary: attr(),
|
||||
/**
|
||||
* Determines to which "thread" (using `mail.activity.mixin` on the
|
||||
* server) `this` belongs to.
|
||||
*/
|
||||
thread: one('Thread', {
|
||||
inverse: 'activities',
|
||||
}),
|
||||
type: one('ActivityType', {
|
||||
inverse: 'activities',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityBoxView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Handles click on activity box title.
|
||||
*/
|
||||
onClickActivityBoxTitle(ev) {
|
||||
ev.preventDefault();
|
||||
this.update({ isActivityListVisible: !this.isActivityListVisible });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activityViews: many('ActivityView', {
|
||||
compute() {
|
||||
return this.chatter.thread.activities.map(activity => {
|
||||
return { activity };
|
||||
});
|
||||
},
|
||||
inverse: 'activityBoxView',
|
||||
}),
|
||||
chatter: one('Chatter', {
|
||||
identifying: true,
|
||||
inverse: 'activityBoxView',
|
||||
}),
|
||||
isActivityListVisible: attr({
|
||||
default: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityButtonView',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
async onClick(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.listFieldActivityViewOwner) {
|
||||
ev.stopPropagation(); // prevents list view click that opens form view. TODO use special_click instead?
|
||||
}
|
||||
await this.webRecord.save();
|
||||
if (this.webRecord.resId) {
|
||||
this.update({ activityListPopoverView: this.activityListPopoverView ? clear() : {} });
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activityListPopoverView: one('PopoverView', {
|
||||
inverse: 'activityButtonViewOwnerAsActivityList',
|
||||
}),
|
||||
buttonClass: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
const classes = [];
|
||||
switch (this.webRecord.data.activity_state) {
|
||||
case 'overdue':
|
||||
classes.push('text-danger');
|
||||
break;
|
||||
case 'today':
|
||||
classes.push('text-warning');
|
||||
break;
|
||||
case 'planned':
|
||||
classes.push('text-success');
|
||||
break;
|
||||
default:
|
||||
classes.push('text-muted');
|
||||
break;
|
||||
}
|
||||
switch (this.webRecord.data.activity_exception_decoration) {
|
||||
case 'warning':
|
||||
classes.push('text-warning');
|
||||
classes.push(this.webRecord.data.activity_exception_icon);
|
||||
break;
|
||||
case 'danger':
|
||||
classes.push('text-danger');
|
||||
classes.push(this.webRecord.data.activity_exception_icon);
|
||||
break;
|
||||
default:
|
||||
if (this.webRecord.data.activity_type_icon) {
|
||||
classes.push(this.webRecord.data.activity_type_icon);
|
||||
break;
|
||||
}
|
||||
classes.push('fa-clock-o');
|
||||
break;
|
||||
}
|
||||
return classes.join(' ');
|
||||
},
|
||||
}),
|
||||
buttonRef: attr(),
|
||||
kanbanFieldActivityViewOwner: one('KanbanFieldActivityView', {
|
||||
identifying: true,
|
||||
inverse: 'activityButtonView',
|
||||
}),
|
||||
listFieldActivityViewOwner: one('ListFieldActivityView', {
|
||||
identifying: true,
|
||||
inverse: 'activityButtonView',
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
compute() {
|
||||
if (this.kanbanFieldActivityViewOwner) {
|
||||
return this.kanbanFieldActivityViewOwner.thread;
|
||||
}
|
||||
if (this.listFieldActivityViewOwner) {
|
||||
return this.listFieldActivityViewOwner.thread;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
webRecord: attr({
|
||||
compute() {
|
||||
if (this.kanbanFieldActivityViewOwner) {
|
||||
return this.kanbanFieldActivityViewOwner.webRecord;
|
||||
}
|
||||
if (this.listFieldActivityViewOwner) {
|
||||
return this.listFieldActivityViewOwner.webRecord;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityGroup',
|
||||
modelMethods: {
|
||||
convertData(data) {
|
||||
return {
|
||||
actions: data.actions,
|
||||
domain: data.domain,
|
||||
irModel: {
|
||||
iconUrl: data.icon,
|
||||
id: data.id,
|
||||
model: data.model,
|
||||
name: data.name,
|
||||
},
|
||||
overdue_count: data.overdue_count,
|
||||
planned_count: data.planned_count,
|
||||
today_count: data.today_count,
|
||||
total_count: data.total_count,
|
||||
type: data.type,
|
||||
};
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeTotalCount() {
|
||||
if (this.type === 'activity' && this.total_count === 0 && this.planned_count === 0) {
|
||||
this.delete();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
actions: attr(),
|
||||
activityGroupViews: many('ActivityGroupView', {
|
||||
inverse: 'activityGroup',
|
||||
}),
|
||||
domain: attr(),
|
||||
irModel: one('ir.model', {
|
||||
identifying: true,
|
||||
inverse: 'activityGroup',
|
||||
}),
|
||||
overdue_count: attr({
|
||||
default: 0,
|
||||
}),
|
||||
planned_count: attr({
|
||||
default: 0,
|
||||
}),
|
||||
today_count: attr({
|
||||
default: 0,
|
||||
}),
|
||||
total_count: attr({
|
||||
default: 0,
|
||||
}),
|
||||
type: attr(),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['total_count', 'type', 'planned_count'],
|
||||
methodName: '_onChangeTotalCount',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
import session from 'web.session';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityGroupView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
ev.stopPropagation();
|
||||
this.activityMenuViewOwner.update({ isOpen: false });
|
||||
const targetAction = $(ev.currentTarget);
|
||||
const actionXmlid = targetAction.data('action_xmlid');
|
||||
if (actionXmlid) {
|
||||
this.env.services.action.doAction(actionXmlid);
|
||||
} else {
|
||||
let domain = [['activity_ids.user_id', '=', session.uid]];
|
||||
if (targetAction.data('domain')) {
|
||||
domain = domain.concat(targetAction.data('domain'));
|
||||
}
|
||||
this.env.services['action'].doAction(
|
||||
{
|
||||
domain,
|
||||
name: targetAction.data('model_name'),
|
||||
res_model: targetAction.data('res_model'),
|
||||
type: 'ir.actions.act_window',
|
||||
views: this.activityGroup.irModel.availableWebViews.map(viewName => [false, viewName]),
|
||||
},
|
||||
{
|
||||
clearBreadcrumbs: true,
|
||||
viewType: 'activity',
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickFilterButton(ev) {
|
||||
this.activityMenuViewOwner.update({ isOpen: false });
|
||||
// fetch the data from the button otherwise fetch the ones from the parent (.o_ActivityMenuView_activityGroup).
|
||||
const data = _.extend({}, $(ev.currentTarget).data(), $(ev.target).data());
|
||||
const context = {};
|
||||
if (data.filter === 'my') {
|
||||
context['search_default_activities_overdue'] = 1;
|
||||
context['search_default_activities_today'] = 1;
|
||||
} else {
|
||||
context['search_default_activities_' + data.filter] = 1;
|
||||
}
|
||||
// Necessary because activity_ids of mail.activity.mixin has auto_join
|
||||
// So, duplicates are faking the count and "Load more" doesn't show up
|
||||
context['force_search_count'] = 1;
|
||||
let domain = [['activity_ids.user_id', '=', session.uid]];
|
||||
if (data.domain) {
|
||||
domain = domain.concat(data.domain);
|
||||
}
|
||||
this.env.services['action'].doAction(
|
||||
{
|
||||
context,
|
||||
domain,
|
||||
name: data.model_name,
|
||||
res_model: data.res_model,
|
||||
search_view_id: [false],
|
||||
type: 'ir.actions.act_window',
|
||||
views: this.activityGroup.irModel.availableWebViews.map(viewName => [false, viewName]),
|
||||
},
|
||||
{
|
||||
clearBreadcrumbs: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activityGroup: one('ActivityGroup', {
|
||||
identifying: true,
|
||||
inverse: 'activityGroupViews',
|
||||
}),
|
||||
activityMenuViewOwner: one('ActivityMenuView', {
|
||||
identifying: true,
|
||||
inverse: 'activityGroupViews',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
|
||||
import session from 'web.session';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityListView',
|
||||
lifecycleHooks: {
|
||||
async _created() {
|
||||
if (this.activities.length === 0) {
|
||||
return;
|
||||
}
|
||||
const messaging = this.messaging;
|
||||
const activitiesData = await this.messaging.rpc({
|
||||
model: 'mail.activity',
|
||||
method: 'activity_format',
|
||||
args: [this.activities.map(activity => activity.id)],
|
||||
kwargs: { context: session.user_context },
|
||||
}, { shadow: true });
|
||||
if (!messaging.exists()) {
|
||||
return;
|
||||
}
|
||||
messaging.models['Activity'].insert(
|
||||
activitiesData.map(activityData => messaging.models['Activity'].convertData(activityData))
|
||||
);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
onClickAddActivityButton() {
|
||||
const thread = this.thread;
|
||||
const webRecord = this.webRecord;
|
||||
this.messaging.openActivityForm({
|
||||
thread,
|
||||
}).then(() => {
|
||||
thread.fetchData(['activities']);
|
||||
webRecord.model.load({ offset: webRecord.model.root.offset });
|
||||
});
|
||||
this.popoverViewOwner.delete();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activities: many('Activity', {
|
||||
compute() {
|
||||
return this.thread && this.thread.activities;
|
||||
},
|
||||
sort: [
|
||||
['truthy-first', 'dateDeadline'],
|
||||
['case-insensitive-asc', 'dateDeadline'],
|
||||
],
|
||||
}),
|
||||
activityListViewItems: many('ActivityListViewItem', {
|
||||
compute() {
|
||||
return this.activities.map(activity => {
|
||||
return {
|
||||
activity,
|
||||
};
|
||||
});
|
||||
},
|
||||
inverse: 'activityListViewOwner',
|
||||
}),
|
||||
overdueActivityListViewItems: many('ActivityListViewItem', {
|
||||
inverse: 'activityListViewOwnerAsOverdue',
|
||||
}),
|
||||
plannedActivityListViewItems: many('ActivityListViewItem', {
|
||||
inverse: 'activityListViewOwnerAsPlanned',
|
||||
}),
|
||||
popoverViewOwner: one('PopoverView', {
|
||||
identifying: true,
|
||||
inverse: 'activityListView',
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
compute() {
|
||||
return this.popoverViewOwner.activityButtonViewOwnerAsActivityList.thread;
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
todayActivityListViewItems: many('ActivityListViewItem', {
|
||||
inverse: 'activityListViewOwnerAsToday',
|
||||
}),
|
||||
webRecord: attr({
|
||||
compute() {
|
||||
return this.popoverViewOwner.activityButtonViewOwnerAsActivityList.webRecord;
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import { auto_str_to_date } from 'web.time';
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityListViewItem',
|
||||
recordMethods: {
|
||||
onClickEditActivityButton() {
|
||||
const popoverViewOwner = this.activityListViewOwner.popoverViewOwner;
|
||||
const webRecord = this.webRecord;
|
||||
this.activity.edit().then(() => {
|
||||
webRecord.model.load({ offset: webRecord.model.root.offset });
|
||||
});
|
||||
popoverViewOwner.delete();
|
||||
},
|
||||
onClickMarkAsDone() {
|
||||
this.update({ markDoneView: this.markDoneView ? clear() : {} });
|
||||
},
|
||||
/**
|
||||
* Handles the click on the upload document button. This open the file
|
||||
* explorer for upload.
|
||||
*/
|
||||
onClickUploadDocument() {
|
||||
this.fileUploader.openBrowserFileUploader();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activity: one('Activity', {
|
||||
identifying: true,
|
||||
}),
|
||||
activityListViewOwner: one('ActivityListView', {
|
||||
identifying: true,
|
||||
inverse: 'activityListViewItems',
|
||||
}),
|
||||
activityListViewOwnerAsOverdue: one('ActivityListView', {
|
||||
compute() {
|
||||
return this.activity.state === 'overdue' ? this.activityListViewOwner : clear();
|
||||
},
|
||||
inverse: 'overdueActivityListViewItems',
|
||||
}),
|
||||
activityListViewOwnerAsPlanned: one('ActivityListView', {
|
||||
compute() {
|
||||
return this.activity.state === 'planned' ? this.activityListViewOwner : clear();
|
||||
},
|
||||
inverse: 'plannedActivityListViewItems',
|
||||
}),
|
||||
activityListViewOwnerAsToday: one('ActivityListView', {
|
||||
compute() {
|
||||
return this.activity.state === 'today' ? this.activityListViewOwner : clear();
|
||||
},
|
||||
inverse: 'todayActivityListViewItems',
|
||||
}),
|
||||
clockWatcher: one('ClockWatcher', {
|
||||
default: {
|
||||
clock: {
|
||||
frequency: 60 * 1000,
|
||||
},
|
||||
},
|
||||
inverse: 'activityListViewItemOwner',
|
||||
}),
|
||||
/**
|
||||
* Compute the label for "when" the activity is due.
|
||||
*/
|
||||
delayLabel: attr({
|
||||
compute() {
|
||||
if (!this.activity.dateDeadline) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.clockWatcher.clock.date) {
|
||||
return clear();
|
||||
}
|
||||
const today = moment(this.clockWatcher.clock.date.getTime()).startOf('day');
|
||||
const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
|
||||
// true means no rounding
|
||||
const diff = momentDeadlineDate.diff(today, 'days', true);
|
||||
if (diff === 0) {
|
||||
return this.env._t("Today");
|
||||
} else if (diff === -1) {
|
||||
return this.env._t("Yesterday");
|
||||
} else if (diff < 0) {
|
||||
return sprintf(this.env._t("%s days overdue"), Math.round(Math.abs(diff)));
|
||||
} else if (diff === 1) {
|
||||
return this.env._t("Tomorrow");
|
||||
} else {
|
||||
return sprintf(this.env._t("Due in %s days"), Math.round(Math.abs(diff)));
|
||||
}
|
||||
},
|
||||
}),
|
||||
fileUploader: one('FileUploader', {
|
||||
compute() {
|
||||
return this.activity.category === 'upload_file' ? {} : clear();
|
||||
},
|
||||
inverse: 'activityListViewItemOwner',
|
||||
}),
|
||||
hasEditButton: attr({
|
||||
compute() {
|
||||
return this.activity.chaining_type === 'suggest' && this.activity.canWrite;
|
||||
},
|
||||
}),
|
||||
hasMarkDoneButton: attr({
|
||||
compute() {
|
||||
return !this.fileUploader;
|
||||
},
|
||||
}),
|
||||
mailTemplateViews: many('MailTemplateView', {
|
||||
compute() {
|
||||
return this.activity.mailTemplates.map(mailTemplate => ({ mailTemplate }));
|
||||
},
|
||||
inverse: 'activityListViewItemOwner',
|
||||
}),
|
||||
markDoneView: one('ActivityMarkDonePopoverContentView', {
|
||||
inverse: 'activityListViewItemOwner',
|
||||
}),
|
||||
webRecord: attr({
|
||||
compute() {
|
||||
return this.activityListViewOwner.webRecord;
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityMarkDonePopoverContentView',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Handles blur on this feedback textarea.
|
||||
*/
|
||||
onBlur() {
|
||||
if (!this.exists() || !this.feedbackTextareaRef || !this.feedbackTextareaRef.el) {
|
||||
return;
|
||||
}
|
||||
this._backupFeedback();
|
||||
},
|
||||
/**
|
||||
* Handles click on this "Discard" button.
|
||||
*/
|
||||
onClickDiscard() {
|
||||
this._close();
|
||||
},
|
||||
/**
|
||||
* Handles click on this "Done" button.
|
||||
*/
|
||||
async onClickDone() {
|
||||
const chatter = this.activityViewOwner && this.activityViewOwner.activityBoxView.chatter;
|
||||
const webRecord = this.webRecord;
|
||||
await this.activity.markAsDone({
|
||||
feedback: this.feedbackTextareaRef.el.value,
|
||||
});
|
||||
if (chatter && chatter.exists() && chatter.component) {
|
||||
chatter.reloadParentView();
|
||||
}
|
||||
if (webRecord) {
|
||||
webRecord.model.load({ offset: webRecord.model.root.offset });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles click on this "Done & Schedule Next" button.
|
||||
*/
|
||||
async onClickDoneAndScheduleNext() {
|
||||
const chatter = this.activityViewOwner && this.activityViewOwner.activityBoxView.chatter;
|
||||
const webRecord = this.webRecord;
|
||||
const activityListViewOwner = this.activityListViewItemOwner && this.activityListViewItemOwner.activityListViewOwner;
|
||||
const activity = this.activity;
|
||||
const feedback = this.feedbackTextareaRef.el.value;
|
||||
if (activityListViewOwner && activityListViewOwner.exists()) {
|
||||
activityListViewOwner.popoverViewOwner.delete();
|
||||
}
|
||||
await activity.markAsDoneAndScheduleNext({ feedback });
|
||||
if (chatter && chatter.exists() && chatter.component) {
|
||||
chatter.reloadParentView();
|
||||
}
|
||||
if (webRecord) {
|
||||
webRecord.model.load({ offset: webRecord.model.root.offset });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles keydown on this activity mark done.
|
||||
*/
|
||||
onKeydown(ev) {
|
||||
if (ev.key === 'Escape') {
|
||||
this._close();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_backupFeedback() {
|
||||
this.activity.update({
|
||||
feedbackBackup: this.feedbackTextareaRef.el.value,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_close() {
|
||||
this._backupFeedback();
|
||||
if (this.activityViewOwner) {
|
||||
this.activityViewOwner.update({ markDonePopoverView: clear() });
|
||||
return;
|
||||
}
|
||||
if (this.activityListViewItemOwner) {
|
||||
this.activityListViewItemOwner.update({ markDoneView: clear() });
|
||||
return;
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activity: one('Activity', {
|
||||
compute() {
|
||||
if (this.activityListViewItemOwner) {
|
||||
return this.activityListViewItemOwner.activity;
|
||||
}
|
||||
if (this.activityViewOwner) {
|
||||
return this.activityViewOwner.activity;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
activityListViewItemOwner: one('ActivityListViewItem', {
|
||||
identifying: true,
|
||||
inverse: 'markDoneView',
|
||||
}),
|
||||
activityViewOwner: one('ActivityView', {
|
||||
compute() {
|
||||
if (this.popoverViewOwner && this.popoverViewOwner.activityViewOwnerAsMarkDone) {
|
||||
return this.popoverViewOwner.activityViewOwnerAsMarkDone;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
component: attr(),
|
||||
feedbackTextareaRef: attr(),
|
||||
hasHeader: attr({
|
||||
compute() {
|
||||
return Boolean(this.popoverViewOwner);
|
||||
},
|
||||
}),
|
||||
headerText: attr({
|
||||
compute() {
|
||||
if (this.activityViewOwner) {
|
||||
return this.activityViewOwner.markDoneText;
|
||||
}
|
||||
return this.env._t("Mark Done");
|
||||
},
|
||||
}),
|
||||
popoverViewOwner: one('PopoverView', {
|
||||
identifying: true,
|
||||
inverse: 'activityMarkDonePopoverContentView',
|
||||
}),
|
||||
webRecord: attr({
|
||||
compute() {
|
||||
if (this.activityListViewItemOwner) {
|
||||
return this.activityListViewItemOwner.webRecord;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many } from '@mail/model/model_field';
|
||||
|
||||
import session from 'web.session';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityMenuView',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.fetchData();
|
||||
document.addEventListener('click', this._onClickCaptureGlobal, true);
|
||||
},
|
||||
_willDelete() {
|
||||
document.removeEventListener('click', this._onClickCaptureGlobal, true);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
close() {
|
||||
this.update({ isOpen: false });
|
||||
},
|
||||
async fetchData() {
|
||||
const data = await this.messaging.rpc({
|
||||
model: 'res.users',
|
||||
method: 'systray_get_activities',
|
||||
args: [],
|
||||
kwargs: { context: session.user_context },
|
||||
});
|
||||
this.update({
|
||||
activityGroups: data.map(vals => this.messaging.models['ActivityGroup'].convertData(vals)),
|
||||
extraCount: 0,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickDropdownToggle(ev) {
|
||||
ev.preventDefault();
|
||||
if (this.isOpen) {
|
||||
this.update({ isOpen: false });
|
||||
} else {
|
||||
this.update({ isOpen: true });
|
||||
this.fetchData();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Closes the menu when clicking outside, if appropriate.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickCaptureGlobal(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (!this.component || !this.component.root.el) {
|
||||
return;
|
||||
}
|
||||
if (this.component.root.el.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activityGroups: many('ActivityGroup', {
|
||||
sort: [['smaller-first', 'irModel.id']],
|
||||
}),
|
||||
activityGroupViews: many('ActivityGroupView', {
|
||||
compute() {
|
||||
return this.activityGroups.map(activityGroup => {
|
||||
return {
|
||||
activityGroup,
|
||||
};
|
||||
});
|
||||
},
|
||||
inverse: 'activityMenuViewOwner',
|
||||
}),
|
||||
component: attr(),
|
||||
counter: attr({
|
||||
compute() {
|
||||
return this.activityGroups.reduce((total, group) => total + group.total_count, this.extraCount);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines the number of activities that have been added in the
|
||||
* system but not yet taken into account in each activity group counter.
|
||||
*
|
||||
* @deprecated this field should be replaced by directly updating the
|
||||
* counter of each group.
|
||||
*/
|
||||
extraCount: attr(),
|
||||
isOpen: attr({
|
||||
default: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityType',
|
||||
fields: {
|
||||
activities: many('Activity', {
|
||||
inverse: 'type',
|
||||
}),
|
||||
displayName: attr(),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import { auto_str_to_date, getLangDateFormat, getLangDatetimeFormat } from 'web.time';
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'ActivityView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Handles the click on a link inside the activity.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickActivity(ev) {
|
||||
await this.messaging.handleClickOnLink(ev);
|
||||
},
|
||||
/**
|
||||
* Handles the click on the cancel button
|
||||
*/
|
||||
async onClickCancel() {
|
||||
const { chatter } = this.activityBoxView; // save value before deleting activity
|
||||
await this.activity.deleteServerRecord();
|
||||
if (chatter.exists() && chatter.component) {
|
||||
chatter.reloadParentView();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles the click on the detail button
|
||||
*/
|
||||
onClickDetailsButton(ev) {
|
||||
ev.preventDefault();
|
||||
this.update({ areDetailsVisible: !this.areDetailsVisible });
|
||||
},
|
||||
/**
|
||||
* Handles the click on the edit button
|
||||
*/
|
||||
async onClickEdit() {
|
||||
const { chatter } = this.activityBoxView;
|
||||
await this.activity.edit();
|
||||
if (chatter.exists() && chatter.component) {
|
||||
chatter.reloadParentView();
|
||||
}
|
||||
},
|
||||
onClickMarkDoneButton() {
|
||||
this.update({ markDonePopoverView: this.markDonePopoverView ? clear() : {} });
|
||||
},
|
||||
/**
|
||||
* Handles the click on the upload document button. This open the file
|
||||
* explorer for upload.
|
||||
*/
|
||||
onClickUploadDocument() {
|
||||
this.fileUploader.openBrowserFileUploader();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activity: one('Activity', {
|
||||
identifying: true,
|
||||
inverse: 'activityViews',
|
||||
}),
|
||||
activityBoxView: one('ActivityBoxView', {
|
||||
identifying: true,
|
||||
inverse: 'activityViews',
|
||||
}),
|
||||
/**
|
||||
* Determines whether the details are visible.
|
||||
*/
|
||||
areDetailsVisible: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Compute the string for the assigned user.
|
||||
*/
|
||||
assignedUserText: attr({
|
||||
compute() {
|
||||
if (!this.activity.assignee) {
|
||||
return clear();
|
||||
}
|
||||
return sprintf(this.env._t("for %s"), this.activity.assignee.nameOrDisplayName);
|
||||
},
|
||||
}),
|
||||
clockWatcher: one('ClockWatcher', {
|
||||
default: {
|
||||
clock: {
|
||||
frequency: 60 * 1000,
|
||||
},
|
||||
},
|
||||
inverse: 'activityViewOwner',
|
||||
}),
|
||||
/**
|
||||
* States the OWL component of this activity view.
|
||||
*/
|
||||
component: attr(),
|
||||
/**
|
||||
* Compute the label for "when" the activity is due.
|
||||
*/
|
||||
delayLabel: attr({
|
||||
compute() {
|
||||
if (!this.activity.dateDeadline) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.clockWatcher.clock.date) {
|
||||
return clear();
|
||||
}
|
||||
const today = moment(this.clockWatcher.clock.date.getTime()).startOf('day');
|
||||
const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
|
||||
// true means no rounding
|
||||
const diff = momentDeadlineDate.diff(today, 'days', true);
|
||||
if (diff === 0) {
|
||||
return this.env._t("Today:");
|
||||
} else if (diff === -1) {
|
||||
return this.env._t("Yesterday:");
|
||||
} else if (diff < 0) {
|
||||
return sprintf(this.env._t("%s days overdue:"), Math.round(Math.abs(diff)));
|
||||
} else if (diff === 1) {
|
||||
return this.env._t("Tomorrow:");
|
||||
} else {
|
||||
return sprintf(this.env._t("Due in %s days:"), Math.round(Math.abs(diff)));
|
||||
}
|
||||
},
|
||||
}),
|
||||
fileUploader: one('FileUploader', {
|
||||
compute() {
|
||||
return this.activity.category === 'upload_file' ? {} : clear();
|
||||
},
|
||||
inverse: 'activityView',
|
||||
}),
|
||||
/**
|
||||
* Format the create date to something human reabable.
|
||||
*/
|
||||
formattedCreateDatetime: attr({
|
||||
compute() {
|
||||
if (!this.activity.dateCreate) {
|
||||
return clear();
|
||||
}
|
||||
const momentCreateDate = moment(auto_str_to_date(this.activity.dateCreate));
|
||||
const datetimeFormat = getLangDatetimeFormat();
|
||||
return momentCreateDate.format(datetimeFormat);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Format the deadline date to something human reabable.
|
||||
*/
|
||||
formattedDeadlineDate: attr({
|
||||
compute() {
|
||||
if (!this.activity.dateDeadline) {
|
||||
return clear();
|
||||
}
|
||||
const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
|
||||
const datetimeFormat = getLangDateFormat();
|
||||
return momentDeadlineDate.format(datetimeFormat);
|
||||
},
|
||||
}),
|
||||
mailTemplateViews: many('MailTemplateView', {
|
||||
compute() {
|
||||
return this.activity.mailTemplates.map(mailTemplate => ({ mailTemplate }));
|
||||
},
|
||||
inverse: 'activityViewOwner',
|
||||
}),
|
||||
markDoneButtonRef: attr(),
|
||||
markDonePopoverView: one('PopoverView', {
|
||||
inverse: 'activityViewOwnerAsMarkDone',
|
||||
}),
|
||||
/**
|
||||
* Label for mark as done. This is just for translations purpose.
|
||||
*/
|
||||
markDoneText: attr({
|
||||
compute() {
|
||||
return this.env._t("Mark Done");
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Format the summary.
|
||||
*/
|
||||
summary: attr({
|
||||
compute() {
|
||||
if (!this.activity.summary) {
|
||||
return clear();
|
||||
}
|
||||
return sprintf(this.env._t("“%s”"), this.activity.summary);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
353
odoo-bringout-oca-ocb-mail/mail/static/src/models/attachment.js
Normal file
353
odoo-bringout-oca-ocb-mail/mail/static/src/models/attachment.js
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear, insert } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'Attachment',
|
||||
modelMethods: {
|
||||
/**
|
||||
* @static
|
||||
* @param {Object} data
|
||||
* @return {Object}
|
||||
*/
|
||||
convertData(data) {
|
||||
const data2 = {};
|
||||
if ('access_token' in data) {
|
||||
data2.accessToken = data.access_token;
|
||||
}
|
||||
if ('checksum' in data) {
|
||||
data2.checksum = data.checksum;
|
||||
}
|
||||
if ('filename' in data) {
|
||||
data2.filename = data.filename;
|
||||
}
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
if ('mimetype' in data) {
|
||||
data2.mimetype = data.mimetype;
|
||||
}
|
||||
if ('name' in data) {
|
||||
data2.name = data.name;
|
||||
}
|
||||
// relation
|
||||
if ('res_id' in data && 'res_model' in data) {
|
||||
data2.originThread = insert({
|
||||
id: data.res_id,
|
||||
model: data.res_model,
|
||||
});
|
||||
}
|
||||
if ('originThread' in data) {
|
||||
data2.originThread = data.originThread;
|
||||
}
|
||||
if ('type' in data) {
|
||||
data2.type = data.type;
|
||||
}
|
||||
if ('url' in data) {
|
||||
data2.url = data.url;
|
||||
}
|
||||
return data2;
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* Send the attachment for the browser to download.
|
||||
*/
|
||||
download() {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.setAttribute('href', this.downloadUrl);
|
||||
// Adding 'download' attribute into a link prevents open a new tab or change the current location of the window.
|
||||
// This avoids interrupting the activity in the page such as rtc call.
|
||||
downloadLink.setAttribute('download', '');
|
||||
downloadLink.click();
|
||||
},
|
||||
/**
|
||||
* Handles click on download icon.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickDownload(ev) {
|
||||
ev.stopPropagation();
|
||||
this.download();
|
||||
},
|
||||
/**
|
||||
* Remove this attachment globally.
|
||||
*/
|
||||
async remove() {
|
||||
if (this.isUnlinkPending) {
|
||||
return;
|
||||
}
|
||||
if (!this.isUploading) {
|
||||
this.update({ isUnlinkPending: true });
|
||||
try {
|
||||
await this.messaging.rpc({
|
||||
route: `/mail/attachment/delete`,
|
||||
params: {
|
||||
access_token: this.accessToken,
|
||||
attachment_id: this.id,
|
||||
},
|
||||
}, { shadow: true });
|
||||
} finally {
|
||||
if (this.exists()) {
|
||||
this.update({ isUnlinkPending: false });
|
||||
}
|
||||
}
|
||||
} else if (this.uploadingAbortController) {
|
||||
this.uploadingAbortController.abort();
|
||||
}
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.messaging.messagingBus.trigger('o-attachment-deleted', { attachment: this });
|
||||
this.delete();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
accessToken: attr(),
|
||||
activities: many('Activity', {
|
||||
inverse: 'attachments',
|
||||
}),
|
||||
allThreads: many('Thread', {
|
||||
inverse: 'allAttachments',
|
||||
readonly: true,
|
||||
}),
|
||||
/**
|
||||
* States the attachment lists that are displaying this attachment.
|
||||
*/
|
||||
attachmentLists: many('AttachmentList', {
|
||||
inverse: 'attachments',
|
||||
}),
|
||||
attachmentViewerViewable: one('AttachmentViewerViewable', {
|
||||
inverse: 'attachmentOwner',
|
||||
}),
|
||||
checksum: attr(),
|
||||
/**
|
||||
* States on which composer this attachment is currently being created.
|
||||
*/
|
||||
composer: one('Composer', {
|
||||
inverse: 'attachments',
|
||||
}),
|
||||
defaultSource: attr({
|
||||
compute() {
|
||||
if (this.isImage) {
|
||||
return `/web/image/${this.id}?signature=${this.checksum}`;
|
||||
}
|
||||
if (this.isPdf) {
|
||||
const pdf_lib = `/web/static/lib/pdfjs/web/viewer.html?file=`
|
||||
if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return `${pdf_lib}/mail/channel/${this.originThread.id}/attachment/${this.id}#pagemode=none`;
|
||||
}
|
||||
const accessToken = this.accessToken ? `?access_token%3D${this.accessToken}` : '';
|
||||
return `${pdf_lib}/web/content/${this.id}${accessToken}#pagemode=none`;
|
||||
}
|
||||
if (this.isUrlYoutube) {
|
||||
const urlArr = this.url.split('/');
|
||||
let token = urlArr[urlArr.length - 1];
|
||||
if (token.includes('watch')) {
|
||||
token = token.split('v=')[1];
|
||||
const amp = token.indexOf('&');
|
||||
if (amp !== -1) {
|
||||
token = token.substring(0, amp);
|
||||
}
|
||||
}
|
||||
return `https://www.youtube.com/embed/${token}`;
|
||||
}
|
||||
if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return `/mail/channel/${this.originThread.id}/attachment/${this.id}`;
|
||||
}
|
||||
const accessToken = this.accessToken ? `?access_token=${this.accessToken}` : '';
|
||||
return `/web/content/${this.id}${accessToken}`;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States the OWL ref of the "dialog" window.
|
||||
*/
|
||||
dialogRef: attr(),
|
||||
displayName: attr({
|
||||
compute() {
|
||||
const displayName = this.name || this.filename;
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
downloadUrl: attr({
|
||||
compute() {
|
||||
if (!this.accessToken && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return `/mail/channel/${this.originThread.id}/attachment/${this.id}?download=true`;
|
||||
}
|
||||
const accessToken = this.accessToken ? `access_token=${this.accessToken}&` : '';
|
||||
return `/web/content/ir.attachment/${this.id}/datas?${accessToken}download=true`;
|
||||
},
|
||||
}),
|
||||
extension: attr({
|
||||
compute() {
|
||||
const extension = this.filename && this.filename.split('.').pop();
|
||||
if (extension) {
|
||||
return extension;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
filename: attr(),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
/**
|
||||
* States whether this attachment is deletable.
|
||||
*/
|
||||
isDeletable: attr({
|
||||
compute() {
|
||||
if (!this.messaging) {
|
||||
return false;
|
||||
}
|
||||
if (this.messages.length && this.originThread && this.originThread.model === 'mail.channel') {
|
||||
return this.messages.some(message => (
|
||||
message.canBeDeleted ||
|
||||
(message.author && message.author === this.messaging.currentPartner) ||
|
||||
(message.guestAuthor && message.guestAuthor === this.messaging.currentGuest)
|
||||
));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States id the attachment is an image.
|
||||
*/
|
||||
isImage: attr({
|
||||
compute() {
|
||||
const imageMimetypes = [
|
||||
'image/bmp',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/tiff',
|
||||
'image/x-icon',
|
||||
];
|
||||
return imageMimetypes.includes(this.mimetype);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States if the attachment is a PDF file.
|
||||
*/
|
||||
isPdf: attr({
|
||||
compute() {
|
||||
return this.mimetype === 'application/pdf';
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States if the attachment is a text file.
|
||||
*/
|
||||
isText: attr({
|
||||
compute() {
|
||||
const textMimeType = [
|
||||
'application/javascript',
|
||||
'application/json',
|
||||
'text/css',
|
||||
'text/html',
|
||||
'text/plain',
|
||||
];
|
||||
return textMimeType.includes(this.mimetype);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* True if an unlink RPC is pending, used to prevent multiple unlink attempts.
|
||||
*/
|
||||
isUnlinkPending: attr({
|
||||
default: false,
|
||||
}),
|
||||
isUploading: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States if the attachment is an url.
|
||||
*/
|
||||
isUrl: attr({
|
||||
compute() {
|
||||
return this.type === 'url' && this.url;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if the attachment is a youtube url.
|
||||
*/
|
||||
isUrlYoutube: attr({
|
||||
compute() {
|
||||
return !!this.url && this.url.includes('youtu');
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States if the attachment is a video.
|
||||
*/
|
||||
isVideo: attr({
|
||||
compute() {
|
||||
const videoMimeTypes = [
|
||||
'audio/mpeg',
|
||||
'video/x-matroska',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
];
|
||||
return videoMimeTypes.includes(this.mimetype);
|
||||
},
|
||||
}),
|
||||
isViewable: attr({
|
||||
compute() {
|
||||
return this.isText || this.isImage || this.isVideo || this.isPdf || this.isUrlYoutube;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
mediaType: attr({
|
||||
compute() {
|
||||
return this.mimetype && this.mimetype.split('/').shift();
|
||||
},
|
||||
}),
|
||||
messages: many('Message', {
|
||||
inverse: 'attachments',
|
||||
}),
|
||||
mimetype: attr({
|
||||
default: '',
|
||||
}),
|
||||
name: attr(),
|
||||
originThread: one('Thread', {
|
||||
inverse: 'originThreadAttachments',
|
||||
}),
|
||||
size: attr(),
|
||||
threads: many('Thread', {
|
||||
inverse: 'attachments',
|
||||
}),
|
||||
threadsAsAttachmentsInWebClientView: many('Thread', {
|
||||
compute() {
|
||||
return (this.isPdf || this.isImage) && !this.isUploading ? this.allThreads : clear();
|
||||
},
|
||||
inverse: 'attachmentsInWebClientView',
|
||||
}),
|
||||
type: attr(),
|
||||
/**
|
||||
* Abort Controller linked to the uploading process of this attachment.
|
||||
* Useful in order to cancel the in-progress uploading of this attachment.
|
||||
*/
|
||||
uploadingAbortController: attr({
|
||||
compute() {
|
||||
if (this.isUploading) {
|
||||
if (!this.uploadingAbortController) {
|
||||
const abortController = new AbortController();
|
||||
abortController.signal.onabort = () => {
|
||||
this.messaging.messagingBus.trigger('o-attachment-upload-abort', {
|
||||
attachment: this
|
||||
});
|
||||
};
|
||||
return abortController;
|
||||
}
|
||||
return this.uploadingAbortController;
|
||||
}
|
||||
return;
|
||||
},
|
||||
}),
|
||||
url: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'AttachmentBoxView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Handles click on the "add attachment" button.
|
||||
*/
|
||||
async onClickAddAttachment() {
|
||||
if (this.chatter.isTemporary) {
|
||||
const chatter = this.chatter;
|
||||
const saved = await this.chatter.doSaveRecord();
|
||||
if (saved) {
|
||||
chatter.attachmentBoxView.fileUploader.openBrowserFileUploader();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.fileUploader.openBrowserFileUploader();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Determines the attachment list that will be used to display the attachments.
|
||||
*/
|
||||
attachmentList: one('AttachmentList', {
|
||||
compute() {
|
||||
return (this.chatter.thread && this.chatter.thread.allAttachments.length > 0)
|
||||
? {}
|
||||
: clear();
|
||||
},
|
||||
inverse: 'attachmentBoxViewOwner',
|
||||
}),
|
||||
chatter: one('Chatter', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentBoxView',
|
||||
}),
|
||||
/**
|
||||
* States the OWL component displaying this attachment box.
|
||||
*/
|
||||
component: attr(),
|
||||
fileUploader: one('FileUploader', {
|
||||
default: {},
|
||||
inverse: 'attachmentBoxView',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'AttachmentCard',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Opens the attachment viewer when clicking on viewable attachment.
|
||||
*/
|
||||
onClickImage() {
|
||||
if (!this.attachment || !this.attachment.isViewable) {
|
||||
return;
|
||||
}
|
||||
this.attachmentList.update({
|
||||
attachmentListViewDialog: {},
|
||||
selectedAttachment: this.attachment,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Handles the click on delete attachment and open the confirm dialog.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickUnlink(ev) {
|
||||
ev.stopPropagation(); // prevents from opening viewer
|
||||
if (!this.attachment) {
|
||||
return;
|
||||
}
|
||||
if (this.attachmentList.composerViewOwner) {
|
||||
this.attachment.remove();
|
||||
} else {
|
||||
this.update({ attachmentDeleteConfirmDialog: {} });
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Determines the attachment of this card.
|
||||
*/
|
||||
attachment: one('Attachment', {
|
||||
identifying: true,
|
||||
}),
|
||||
attachmentDeleteConfirmDialog: one('Dialog', {
|
||||
inverse: 'attachmentCardOwnerAsAttachmentDeleteConfirm',
|
||||
}),
|
||||
/**
|
||||
* States the attachmentList displaying this card.
|
||||
*/
|
||||
attachmentList: one('AttachmentList', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentCards',
|
||||
}),
|
||||
hasMultipleActions: attr({
|
||||
compute() {
|
||||
return this.attachment.isDeletable && !this.attachmentList.composerViewOwner;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'AttachmentDeleteConfirmView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Returns whether the given html element is inside this attachment delete confirm view.
|
||||
*
|
||||
* @param {Element} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
containsElement(element) {
|
||||
return Boolean(this.component && this.component.root.el && this.component.root.el.contains(element));
|
||||
},
|
||||
onClickCancel() {
|
||||
this.dialogOwner.delete();
|
||||
},
|
||||
async onClickOk() {
|
||||
const chatter = this.chatter;
|
||||
await this.attachment.remove();
|
||||
if (chatter && chatter.exists() && chatter.shouldReloadParentFromFileChanged) {
|
||||
chatter.reloadParentView();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
attachment: one('Attachment', {
|
||||
compute() {
|
||||
if (this.dialogOwner && this.dialogOwner.attachmentCardOwnerAsAttachmentDeleteConfirm) {
|
||||
return this.dialogOwner.attachmentCardOwnerAsAttachmentDeleteConfirm.attachment;
|
||||
}
|
||||
if (this.dialogOwner && this.dialogOwner.attachmentImageOwnerAsAttachmentDeleteConfirm) {
|
||||
return this.dialogOwner.attachmentImageOwnerAsAttachmentDeleteConfirm.attachment;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
body: attr({
|
||||
compute() {
|
||||
return sprintf(this.env._t(`Do you really want to delete "%s"?`), this.attachment.displayName);
|
||||
},
|
||||
}),
|
||||
chatter: one('Chatter', {
|
||||
compute() {
|
||||
if (
|
||||
this.dialogOwner.attachmentCardOwnerAsAttachmentDeleteConfirm &&
|
||||
this.dialogOwner.attachmentCardOwnerAsAttachmentDeleteConfirm.attachmentList.attachmentBoxViewOwner &&
|
||||
this.dialogOwner.attachmentCardOwnerAsAttachmentDeleteConfirm.attachmentList.attachmentBoxViewOwner.chatter
|
||||
) {
|
||||
return this.dialogOwner.attachmentCardOwnerAsAttachmentDeleteConfirm.attachmentList.attachmentBoxViewOwner.chatter;
|
||||
}
|
||||
if (
|
||||
this.dialogOwner.attachmentImageOwnerAsAttachmentDeleteConfirm &&
|
||||
this.dialogOwner.attachmentImageOwnerAsAttachmentDeleteConfirm.attachmentList.attachmentBoxViewOwner &&
|
||||
this.dialogOwner.attachmentImageOwnerAsAttachmentDeleteConfirm.attachmentList.attachmentBoxViewOwner.chatter
|
||||
) {
|
||||
return this.dialogOwner.attachmentImageOwnerAsAttachmentDeleteConfirm.attachmentList.attachmentBoxViewOwner.chatter;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
component: attr(),
|
||||
dialogOwner: one('Dialog', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentDeleteConfirmView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { isEventHandled, markEventHandled } from '@mail/utils/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'AttachmentImage',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Called when clicking on download icon.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickDownload(ev) {
|
||||
markEventHandled(ev, 'AttachmentImage.onClickDownload');
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.attachment.download();
|
||||
},
|
||||
/**
|
||||
* Opens the attachment viewer when clicking on viewable attachment.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickImage(ev) {
|
||||
if (isEventHandled(ev, 'AttachmentImage.onClickDownload')) {
|
||||
return;
|
||||
}
|
||||
if (isEventHandled(ev, 'AttachmentImage.onClickUnlink')) {
|
||||
return;
|
||||
}
|
||||
if (!this.attachment || !this.attachment.isViewable) {
|
||||
return;
|
||||
}
|
||||
this.attachmentList.update({
|
||||
attachmentListViewDialog: {},
|
||||
selectedAttachment: this.attachment,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Handles the click on delete attachment and open the confirm dialog.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickUnlink(ev) {
|
||||
markEventHandled(ev, 'AttachmentImage.onClickUnlink');
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.attachmentList.composerViewOwner) {
|
||||
this.attachment.remove();
|
||||
} else {
|
||||
this.update({ attachmentDeleteConfirmDialog: {} });
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Determines the attachment of this attachment image..
|
||||
*/
|
||||
attachment: one('Attachment', {
|
||||
identifying: true,
|
||||
}),
|
||||
attachmentDeleteConfirmDialog: one('Dialog', {
|
||||
inverse: 'attachmentImageOwnerAsAttachmentDeleteConfirm',
|
||||
}),
|
||||
/**
|
||||
* States the attachmentList displaying this attachment image.
|
||||
*/
|
||||
attachmentList: one('AttachmentList', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentImages',
|
||||
}),
|
||||
/**
|
||||
* Determines whether `this` should display a download button.
|
||||
*/
|
||||
hasDownloadButton: attr({
|
||||
compute() {
|
||||
if (!this.attachment || !this.attachmentList) {
|
||||
return clear();
|
||||
}
|
||||
return !this.attachmentList.composerViewOwner && !this.attachment.isUploading;
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines the max height of this attachment image in px.
|
||||
*/
|
||||
height: attr({
|
||||
compute() {
|
||||
if (!this.attachmentList) {
|
||||
return clear();
|
||||
}
|
||||
if (this.attachmentList.composerViewOwner) {
|
||||
return 50;
|
||||
}
|
||||
if (this.attachmentList.attachmentBoxViewOwner) {
|
||||
return 160;
|
||||
}
|
||||
if (this.attachmentList.messageViewOwner) {
|
||||
return 300;
|
||||
}
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
imageUrl: attr({
|
||||
compute() {
|
||||
if (!this.attachment) {
|
||||
return;
|
||||
}
|
||||
if (!this.attachment.accessToken && this.attachment.originThread && this.attachment.originThread.model === 'mail.channel') {
|
||||
return `/mail/channel/${this.attachment.originThread.id}/image/${this.attachment.id}/${this.width}x${this.height}`;
|
||||
}
|
||||
const accessToken = this.attachment.accessToken ? `?access_token=${this.attachment.accessToken}` : '';
|
||||
return `/web/image/${this.attachment.id}/${this.width}x${this.height}${accessToken}`;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines the max width of this attachment image in px.
|
||||
*/
|
||||
width: attr({
|
||||
/**
|
||||
* Returns an arbitrary high value, this is effectively a max-width and
|
||||
* the height should be more constrained.
|
||||
*/
|
||||
compute() {
|
||||
return 1920;
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'AttachmentList',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Select the next attachment.
|
||||
*/
|
||||
selectNextAttachment() {
|
||||
const index = this.attachments.findIndex(attachment => attachment === this.selectedAttachment);
|
||||
const nextIndex = index === this.attachments.length - 1 ? 0 : index + 1;
|
||||
this.update({ selectedAttachment: this.attachments[nextIndex] });
|
||||
},
|
||||
/**
|
||||
* Select the previous attachment.
|
||||
*/
|
||||
selectPreviousAttachment() {
|
||||
const index = this.attachments.findIndex(attachment => attachment === this.selectedAttachment);
|
||||
const prevIndex = index === 0 ? this.attachments.length - 1 : index - 1;
|
||||
this.update({ selectedAttachment: this.attachments[prevIndex] });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Link with a AttachmentBoxView to handle attachments.
|
||||
*/
|
||||
attachmentBoxViewOwner: one('AttachmentBoxView', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentList',
|
||||
}),
|
||||
/**
|
||||
* States the attachment cards that are displaying this nonImageAttachments.
|
||||
*/
|
||||
attachmentCards: many('AttachmentCard', {
|
||||
compute() {
|
||||
return this.nonImageAttachments.map(attachment => ({ attachment }));
|
||||
},
|
||||
inverse: 'attachmentList',
|
||||
}),
|
||||
/**
|
||||
* States the attachment images that are displaying this imageAttachments.
|
||||
*/
|
||||
attachmentImages: many('AttachmentImage', {
|
||||
compute() {
|
||||
return this.imageAttachments.map(attachment => ({ attachment }));
|
||||
},
|
||||
inverse: 'attachmentList',
|
||||
}),
|
||||
attachmentListViewDialog: one('Dialog', {
|
||||
inverse: 'attachmentListOwnerAsAttachmentView',
|
||||
}),
|
||||
/**
|
||||
* States the attachments to be displayed by this attachment list.
|
||||
*/
|
||||
attachments: many('Attachment', {
|
||||
compute() {
|
||||
if (this.messageViewOwner) {
|
||||
return this.messageViewOwner.message.attachments;
|
||||
}
|
||||
if (this.attachmentBoxViewOwner) {
|
||||
return this.attachmentBoxViewOwner.chatter.thread.allAttachments;
|
||||
}
|
||||
if (this.composerViewOwner && this.composerViewOwner.composer) {
|
||||
return this.composerViewOwner.composer.attachments;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'attachmentLists',
|
||||
}),
|
||||
/**
|
||||
* Link with a composer view to handle attachments.
|
||||
*/
|
||||
composerViewOwner: one('ComposerView', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentList',
|
||||
}),
|
||||
/**
|
||||
* States the attachment that are an image.
|
||||
*/
|
||||
imageAttachments: many('Attachment', {
|
||||
compute() {
|
||||
return this.attachments.filter(attachment => attachment.isImage);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if we are in the Discuss view.
|
||||
*/
|
||||
isInDiscuss: attr({
|
||||
compute() {
|
||||
return Boolean(
|
||||
(this.messageViewOwner && this.messageViewOwner.isInDiscuss) ||
|
||||
(this.composerViewOwner && this.composerViewOwner.isInDiscuss)
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if we are in the ChatWindow view.
|
||||
*/
|
||||
isInChatWindow: attr({
|
||||
compute() {
|
||||
return Boolean(
|
||||
(this.messageViewOwner && this.messageViewOwner.isInChatWindow) ||
|
||||
(this.composerViewOwner && this.composerViewOwner.isInChatWindow)
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if we are in the Chatter view.
|
||||
*/
|
||||
isInChatter: attr({
|
||||
compute() {
|
||||
return Boolean(
|
||||
(this.messageViewOwner && this.messageViewOwner.isInChatter) ||
|
||||
(this.composerViewOwner && this.composerViewOwner.isInChatter)
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if it comes from the current user.
|
||||
*/
|
||||
isCurrentUserOrGuestAuthor: attr({
|
||||
compute() {
|
||||
return Boolean(
|
||||
this.composerViewOwner ||
|
||||
(this.messageViewOwner && this.messageViewOwner.message.isCurrentUserOrGuestAuthor)
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if we are in the ChatWindow view AND if the message is right aligned
|
||||
*/
|
||||
isInChatWindowAndIsAlignedRight: attr({
|
||||
compute() {
|
||||
return Boolean(
|
||||
this.isInChatWindow &&
|
||||
this.isCurrentUserOrGuestAuthor
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if we are in the ChatWindow view AND if the message is left aligned
|
||||
*/
|
||||
isInChatWindowAndIsAlignedLeft: attr({
|
||||
compute() {
|
||||
return Boolean(
|
||||
this.isInChatWindow &&
|
||||
!this.isCurrentUserOrGuestAuthor
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Link with a message view to handle attachments.
|
||||
*/
|
||||
messageViewOwner: one('MessageView', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentList',
|
||||
}),
|
||||
/**
|
||||
* States the attachment that are not an image.
|
||||
*/
|
||||
nonImageAttachments: many('Attachment', {
|
||||
compute() {
|
||||
return this.attachments.filter(attachment => !attachment.isImage);
|
||||
},
|
||||
}),
|
||||
selectedAttachment: one('Attachment'),
|
||||
/**
|
||||
* States the attachments that can be viewed inside the browser.
|
||||
*/
|
||||
viewableAttachments: many('Attachment', {
|
||||
compute() {
|
||||
return this.attachments.filter(attachment => attachment.isViewable);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'AttachmentViewer',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Close the dialog with this attachment viewer.
|
||||
*/
|
||||
close() {
|
||||
this.delete();
|
||||
},
|
||||
/**
|
||||
* Returns whether the given html element is inside this attachment viewer.
|
||||
*
|
||||
* @param {Element} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
containsElement(element) {
|
||||
return Boolean(this.component && this.component.root.el && this.component.root.el.contains(element));
|
||||
},
|
||||
/**
|
||||
* Display the next attachment in the list of attachments.
|
||||
*/
|
||||
next() {
|
||||
if (!this.dialogOwner || !this.dialogOwner.attachmentListOwnerAsAttachmentView) {
|
||||
return;
|
||||
}
|
||||
this.dialogOwner.attachmentListOwnerAsAttachmentView.selectNextAttachment();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on mask of attachment viewer.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
if (this.isDragging) {
|
||||
return;
|
||||
}
|
||||
// TODO: clicking on the background should probably be handled by the dialog?
|
||||
// task-2092965
|
||||
this.close();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on cross icon.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickClose(ev) {
|
||||
this.close();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on download icon.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickDownload(ev) {
|
||||
ev.stopPropagation();
|
||||
this.attachmentViewerViewable.download();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on the header. Stop propagation of event to prevent
|
||||
* closing the dialog.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickHeader(ev) {
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on image. Stop propagation of event to prevent
|
||||
* closing the dialog.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickImage(ev) {
|
||||
if (this.isDragging) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on next icon.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickNext(ev) {
|
||||
ev.stopPropagation();
|
||||
this.next();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on previous icon.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickPrevious(ev) {
|
||||
ev.stopPropagation();
|
||||
this.previous();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on print icon.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickPrint(ev) {
|
||||
ev.stopPropagation();
|
||||
this.print();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on rotate icon.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickRotate(ev) {
|
||||
ev.stopPropagation();
|
||||
this.rotate();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on embed video player. Stop propagation to prevent
|
||||
* closing the dialog.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickVideo(ev) {
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* Called when new image has been loaded
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onLoadImage(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.update({ isImageLoading: false });
|
||||
},
|
||||
/**
|
||||
* Display the previous attachment in the list of attachments.
|
||||
*/
|
||||
previous() {
|
||||
if (!this.dialogOwner || !this.dialogOwner.attachmentListOwnerAsAttachmentView) {
|
||||
return;
|
||||
}
|
||||
this.dialogOwner.attachmentListOwnerAsAttachmentView.selectPreviousAttachment();
|
||||
},
|
||||
/**
|
||||
* Prompt the browser print of this attachment.
|
||||
*/
|
||||
print() {
|
||||
const printWindow = window.open('about:blank', '_new');
|
||||
printWindow.document.open();
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
function onloadImage() {
|
||||
setTimeout('printImage()', 10);
|
||||
}
|
||||
function printImage() {
|
||||
window.print();
|
||||
window.close();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload='onloadImage()'>
|
||||
<img src="${this.attachmentViewerViewable.imageUrl}" alt=""/>
|
||||
</body>
|
||||
</html>`);
|
||||
printWindow.document.close();
|
||||
},
|
||||
/**
|
||||
* Rotate the image by 90 degrees to the right.
|
||||
*/
|
||||
rotate() {
|
||||
this.update({ angle: this.angle + 90 });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Angle of the image. Changes when the user rotates it.
|
||||
*/
|
||||
angle: attr({
|
||||
default: 0,
|
||||
}),
|
||||
attachmentList: one('AttachmentList', {
|
||||
related: 'dialogOwner.attachmentListOwnerAsAttachmentView',
|
||||
}),
|
||||
attachmentViewerViewable: one("AttachmentViewerViewable", {
|
||||
compute() {
|
||||
if (this.attachmentList) {
|
||||
return {
|
||||
attachmentOwner: this.attachmentList.selectedAttachment,
|
||||
};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
attachmentViewerViewables: many("AttachmentViewerViewable", {
|
||||
compute() {
|
||||
if (this.attachmentList) {
|
||||
return this.attachmentList.viewableAttachments.map(attachment => {
|
||||
return { attachmentOwner: attachment };
|
||||
});
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States the OWL component of this attachment viewer.
|
||||
*/
|
||||
component: attr(),
|
||||
/**
|
||||
* Determines the dialog displaying this attachment viewer.
|
||||
*/
|
||||
dialogOwner: one('Dialog', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentViewer',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* Style of the image (scale + rotation).
|
||||
*/
|
||||
imageStyle: attr({
|
||||
compute() {
|
||||
let style = `transform: ` +
|
||||
`scale3d(${this.scale}, ${this.scale}, 1) ` +
|
||||
`rotate(${this.angle}deg);`;
|
||||
|
||||
if (this.angle % 180 !== 0) {
|
||||
style += `` +
|
||||
`max-height: ${window.innerWidth}px; ` +
|
||||
`max-width: ${window.innerHeight}px;`;
|
||||
} else {
|
||||
style += `` +
|
||||
`max-height: 100%; ` +
|
||||
`max-width: 100%;`;
|
||||
}
|
||||
return style;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determine whether the user is currently dragging the image.
|
||||
* This is useful to determine whether a click outside of the image
|
||||
* should close the attachment viewer or not.
|
||||
*/
|
||||
isDragging: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determine whether the image is loading or not. Useful to diplay
|
||||
* a spinner when loading image initially.
|
||||
*/
|
||||
isImageLoading: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Scale size of the image. Changes when user zooms in/out.
|
||||
*/
|
||||
scale: attr({
|
||||
default: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from "@mail/model/model_core";
|
||||
import { attr, one } from "@mail/model/model_field";
|
||||
|
||||
/**
|
||||
* Intermediary model to facilitate adding support for additional
|
||||
* models to the AttachmentViewer.
|
||||
*/
|
||||
|
||||
registerModel({
|
||||
name: "AttachmentViewerViewable",
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
download() {
|
||||
return this.attachmentOwner.download();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
attachmentOwner: one("Attachment", {
|
||||
identifying: true,
|
||||
inverse: 'attachmentViewerViewable',
|
||||
}),
|
||||
defaultSource: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.defaultSource;
|
||||
},
|
||||
}),
|
||||
displayName: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.displayName;
|
||||
},
|
||||
}),
|
||||
imageUrl: attr({
|
||||
compute() {
|
||||
if (
|
||||
!this.attachmentOwner.accessToken &&
|
||||
this.attachmentOwner.originThread &&
|
||||
this.attachmentOwner.originThread.model === "mail.channel"
|
||||
) {
|
||||
return `/mail/channel/${this.attachmentOwner.originThread.id}/image/${this.attachmentOwner.id}`;
|
||||
}
|
||||
const accessToken = this.attachmentOwner.accessToken
|
||||
? `?access_token=${this.attachmentOwner.accessToken}`
|
||||
: "";
|
||||
return `/web/image/${this.attachmentOwner.id}${accessToken}`;
|
||||
},
|
||||
}),
|
||||
isImage: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.isImage;
|
||||
},
|
||||
}),
|
||||
isPdf: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.isPdf;
|
||||
},
|
||||
}),
|
||||
isText: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.isText;
|
||||
},
|
||||
}),
|
||||
isUrlYoutube: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.isUrlYoutube;
|
||||
},
|
||||
}),
|
||||
isVideo: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.isVideo;
|
||||
},
|
||||
}),
|
||||
isViewable: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.isViewable;
|
||||
},
|
||||
}),
|
||||
mimetype: attr({
|
||||
compute() {
|
||||
return this.attachmentOwner.mimetype;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'AutocompleteInputView',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
onBlur() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
this.discussSidebarCategoryOwnerAsAddingItem.onHideAddingItem();
|
||||
return;
|
||||
}
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader) {
|
||||
this.discussViewOwnerAsMobileAddItemHeader.onHideMobileAddItemHeader();
|
||||
return;
|
||||
}
|
||||
if (this.messagingMenuOwnerAsMobileNewMessageInput) {
|
||||
this.messagingMenuOwnerAsMobileNewMessageInput.onHideMobileNewMessage();
|
||||
return;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {FocusEvent} ev
|
||||
*/
|
||||
onFocusin(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.chatWindowOwnerAsNewMessage) {
|
||||
this.chatWindowOwnerAsNewMessage.onFocusInNewMessageFormInput(ev);
|
||||
return;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onKeydown(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
this.onBlur();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onSelect(ev, ui) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.chatWindowOwnerAsNewMessage) {
|
||||
this.chatWindowOwnerAsNewMessage.onAutocompleteSelect(ev, ui);
|
||||
return;
|
||||
}
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
this.discussSidebarCategoryOwnerAsAddingItem.onAddItemAutocompleteSelect(ev, ui);
|
||||
return;
|
||||
}
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader) {
|
||||
this.discussViewOwnerAsMobileAddItemHeader.onMobileAddItemHeaderInputSelect(ev, ui);
|
||||
return;
|
||||
}
|
||||
if (this.messagingMenuOwnerAsMobileNewMessageInput) {
|
||||
this.messagingMenuOwnerAsMobileNewMessageInput.onMobileNewMessageInputSelect(ev, ui);
|
||||
return;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {function} res
|
||||
*/
|
||||
onSource(req, res) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.chatWindowOwnerAsNewMessage) {
|
||||
this.chatWindowOwnerAsNewMessage.onAutocompleteSource(req, res);
|
||||
return;
|
||||
}
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
this.discussSidebarCategoryOwnerAsAddingItem.onAddItemAutocompleteSource(req, res);
|
||||
return;
|
||||
}
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader) {
|
||||
this.discussViewOwnerAsMobileAddItemHeader.onMobileAddItemHeaderInputSource(req, res);
|
||||
return;
|
||||
}
|
||||
if (this.messagingMenuOwnerAsMobileNewMessageInput) {
|
||||
this.messagingMenuOwnerAsMobileNewMessageInput.onMobileNewMessageInputSource(req, res);
|
||||
return;
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
chatWindowOwnerAsNewMessage: one('ChatWindow', {
|
||||
identifying: true,
|
||||
inverse: 'newMessageAutocompleteInputView',
|
||||
}),
|
||||
component: attr(),
|
||||
customClass: attr({
|
||||
compute() {
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem === this.messaging.discuss.categoryChannel) {
|
||||
return 'o_DiscussSidebarCategory_newChannelAutocompleteSuggestions';
|
||||
}
|
||||
}
|
||||
if (this.messagingMenuOwnerAsMobileNewMessageInput) {
|
||||
return this.messagingMenuOwnerAsMobileNewMessageInput.viewId + '_mobileNewMessageInputAutocomplete';
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
discussSidebarCategoryOwnerAsAddingItem: one('DiscussSidebarCategory', {
|
||||
identifying: true,
|
||||
inverse: 'addingItemAutocompleteInputView',
|
||||
}),
|
||||
discussViewOwnerAsMobileAddItemHeader: one('DiscussView', {
|
||||
identifying: true,
|
||||
inverse: 'mobileAddItemHeaderAutocompleteInputView',
|
||||
}),
|
||||
isFocusOnMount: attr({
|
||||
compute() {
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader) {
|
||||
return true;
|
||||
}
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
return true;
|
||||
}
|
||||
if (this.messagingMenuOwnerAsMobileNewMessageInput) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
isHtml: attr({
|
||||
compute() {
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader) {
|
||||
return this.discussViewOwnerAsMobileAddItemHeader.isAddingChannel;
|
||||
}
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
return this.discussSidebarCategoryOwnerAsAddingItem === this.messaging.discuss.categoryChannel;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
messagingMenuOwnerAsMobileNewMessageInput: one('MessagingMenu', {
|
||||
identifying: true,
|
||||
inverse: 'mobileNewMessageAutocompleteInputView',
|
||||
}),
|
||||
placeholder: attr({
|
||||
compute() {
|
||||
if (this.chatWindowOwnerAsNewMessage) {
|
||||
return this.chatWindowOwnerAsNewMessage.newMessageFormInputPlaceholder;
|
||||
}
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader) {
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader.isAddingChannel) {
|
||||
return this.discussViewOwnerAsMobileAddItemHeader.discuss.addChannelInputPlaceholder;
|
||||
} else {
|
||||
return this.discussViewOwnerAsMobileAddItemHeader.discuss.addChatInputPlaceholder;
|
||||
}
|
||||
}
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
return this.discussSidebarCategoryOwnerAsAddingItem.newItemPlaceholderText;
|
||||
}
|
||||
if (this.messagingMenuOwnerAsMobileNewMessageInput) {
|
||||
return this.messagingMenuOwnerAsMobileNewMessageInput.mobileNewMessageInputPlaceholder;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
function drawAndBlurImageOnCanvas(image, blurAmount, canvas) {
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
if (blurAmount === 0) {
|
||||
canvas.getContext('2d').drawImage(image, 0, 0, image.width, image.height);
|
||||
return;
|
||||
}
|
||||
canvas.getContext('2d').clearRect(0, 0, image.width, image.height);
|
||||
canvas.getContext('2d').save();
|
||||
// FIXME : Does not work on safari https://bugs.webkit.org/show_bug.cgi?id=198416
|
||||
canvas.getContext('2d').filter = `blur(${blurAmount}px)`;
|
||||
canvas.getContext('2d').drawImage(image, 0, 0, image.width, image.height);
|
||||
canvas.getContext('2d').restore();
|
||||
}
|
||||
|
||||
registerModel({
|
||||
name: 'BlurManager',
|
||||
lifecycleHooks: {
|
||||
_willDelete() {
|
||||
this.video.removeEventListener('loadeddata', this._onVideoPlay);
|
||||
if (this.selfieSegmentation) {
|
||||
this.selfieSegmentation.reset();
|
||||
}
|
||||
this.video.srcObject = null;
|
||||
if (this.rejectStreamPromise) {
|
||||
this.rejectStreamPromise(new Error(this.env._t('The blur manager was removed before the beginning of the blur process')));
|
||||
}
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
onRequestFrameTimerTimeout() {
|
||||
this._requestFrame();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_drawWithCompositing(image, compositeOperation) {
|
||||
this.canvas.getContext('2d').globalCompositeOperation = compositeOperation;
|
||||
this.canvas.getContext('2d').drawImage(image, 0, 0);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeBackgroundBlurAmountSetting() {
|
||||
if (!this.selfieSegmentation) {
|
||||
return;
|
||||
}
|
||||
this.selfieSegmentation.setOptions({
|
||||
backgroundBlur: this.userSetting.backgroundBlurAmount,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeEdgeBlurAmountSetting() {
|
||||
if (!this.selfieSegmentation) {
|
||||
return;
|
||||
}
|
||||
this.selfieSegmentation.setOptions({
|
||||
edgeBlurAmount: this.userSetting.edgeBlurAmount,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _onChangeSrcStream() {
|
||||
this.video.srcObject = null;
|
||||
if (this.selfieSegmentation) {
|
||||
this.selfieSegmentation.reset();
|
||||
}
|
||||
if (this.rejectStreamPromise) {
|
||||
this.rejectStreamPromise(new Error(this.env._t('The source stream was removed before the beginning of the blur process')));
|
||||
}
|
||||
if (!this.srcStream) {
|
||||
return;
|
||||
}
|
||||
let rejectStreamPromise;
|
||||
let resolveStreamPromise;
|
||||
this.update({
|
||||
isVideoDataLoaded: false,
|
||||
stream: new Promise((resolve, reject) => {
|
||||
rejectStreamPromise = reject;
|
||||
resolveStreamPromise = resolve;
|
||||
}),
|
||||
rejectStreamPromise,
|
||||
resolveStreamPromise,
|
||||
});
|
||||
if (!this.selfieSegmentation) {
|
||||
rejectStreamPromise(
|
||||
new Error(this.env._t("The selfie segmentation library was not loaded"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.video.srcObject = this.srcStream.webMediaStream;
|
||||
this.video.load();
|
||||
this.selfieSegmentation.setOptions({
|
||||
selfieMode: false,
|
||||
backgroundBlur: this.userSetting.backgroundBlurAmount,
|
||||
edgeBlur: this.userSetting.edgeBlurAmount,
|
||||
modelSelection: 1,
|
||||
});
|
||||
this.selfieSegmentation.onResults(this._onSelfieSegmentationResults);
|
||||
this.video.addEventListener('loadeddata', this._onVideoPlay);
|
||||
this.video.autoplay = true;
|
||||
Promise.resolve(this.video.play()).catch(()=>{});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _onFrame() {
|
||||
if (!this.selfieSegmentation) {
|
||||
return;
|
||||
}
|
||||
if (!this.video) {
|
||||
return;
|
||||
}
|
||||
if (!this.srcStream) {
|
||||
return;
|
||||
}
|
||||
if (!this.isVideoDataLoaded) {
|
||||
return;
|
||||
}
|
||||
await this.selfieSegmentation.send({ image: this.video });
|
||||
this.update({ frameRequestTimer: { doReset: true } });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onSelfieSegmentationResults(results) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
drawAndBlurImageOnCanvas(
|
||||
results.image,
|
||||
this.userSetting.backgroundBlurAmount,
|
||||
this.canvasBlur,
|
||||
);
|
||||
this.canvas.width = this.canvasBlur.width;
|
||||
this.canvas.height = this.canvasBlur.height;
|
||||
drawAndBlurImageOnCanvas(
|
||||
results.segmentationMask,
|
||||
this.userSetting.edgeBlurAmount,
|
||||
this.canvasMask,
|
||||
);
|
||||
this.canvas.getContext('2d').save();
|
||||
this.canvas.getContext('2d').drawImage(
|
||||
results.image,
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
this._drawWithCompositing(
|
||||
this.canvasMask,
|
||||
'destination-in',
|
||||
);
|
||||
this._drawWithCompositing(
|
||||
this.canvasBlur,
|
||||
'destination-over',
|
||||
);
|
||||
this.canvas.getContext('2d').restore();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onVideoPlay() {
|
||||
this.update({
|
||||
isVideoDataLoaded: true,
|
||||
});
|
||||
this._requestFrame();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_requestFrame() {
|
||||
window.requestAnimationFrame(async () => {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
await this._onFrame();
|
||||
this.resolveStreamPromise(this.canvasStream);
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
canvas: attr({
|
||||
default: document.createElement('canvas'),
|
||||
}),
|
||||
canvasBlur: attr({
|
||||
default: document.createElement('canvas'),
|
||||
}),
|
||||
canvasMask: attr({
|
||||
default: document.createElement('canvas'),
|
||||
}),
|
||||
canvasStream: one('MediaStream', {
|
||||
compute() {
|
||||
if (this.srcStream) {
|
||||
this.canvas.getContext('2d'); // canvas.captureStream() doesn't work on firefox before getContext() is called.
|
||||
const webMediaStream = this.canvas.captureStream();
|
||||
return { webMediaStream, id: webMediaStream.id };
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
isCausal: true,
|
||||
}),
|
||||
frameRequestTimer: one('Timer', {
|
||||
inverse: 'blurManagerOwnerAsFrameRequest',
|
||||
}),
|
||||
isVideoDataLoaded: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* promise reject function of this.stream promise
|
||||
*/
|
||||
rejectStreamPromise: attr(),
|
||||
/**
|
||||
* promise resolve function of this.stream promise
|
||||
*/
|
||||
resolveStreamPromise: attr(),
|
||||
rtc: one('Rtc', {
|
||||
identifying: true,
|
||||
inverse: 'blurManager',
|
||||
}),
|
||||
selfieSegmentation: attr({
|
||||
default: window.SelfieSegmentation
|
||||
? new window.SelfieSegmentation({
|
||||
locateFile: (file) => {
|
||||
return `https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation@0.1/${file}`;
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
/**
|
||||
* mail.MediaStream, source stream for which the blur effect is computed.
|
||||
*/
|
||||
srcStream: one('MediaStream', {
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* Promise or undefined, based on this.srcStream, resolved when selfieSegmentation has started painting on the canvas,
|
||||
* resolves into a web.MediaStream that is the blurred version of this.srcStream.
|
||||
*/
|
||||
stream: attr(),
|
||||
userSetting: one('UserSetting', {
|
||||
related: 'messaging.userSetting',
|
||||
}),
|
||||
video: attr({
|
||||
default: document.createElement('video'),
|
||||
}),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['userSetting.edgeBlurAmount'],
|
||||
methodName: '_onChangeEdgeBlurAmountSetting',
|
||||
},
|
||||
{
|
||||
dependencies: ['userSetting.backgroundBlurAmount'],
|
||||
methodName: '_onChangeBackgroundBlurAmountSetting',
|
||||
},
|
||||
{
|
||||
dependencies: ['srcStream'],
|
||||
methodName: '_onChangeSrcStream',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'CallActionListView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickCamera(ev) {
|
||||
this.messaging.rtc.toggleUserVideo();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickDeafen(ev) {
|
||||
if (this.messaging.rtc.currentRtcSession.isDeaf) {
|
||||
this.messaging.rtc.undeafen();
|
||||
} else {
|
||||
this.messaging.rtc.deafen();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickMicrophone(ev) {
|
||||
if (this.messaging.rtc.currentRtcSession.isMute) {
|
||||
if (this.messaging.rtc.currentRtcSession.isSelfMuted) {
|
||||
this.messaging.rtc.unmute();
|
||||
}
|
||||
if (this.messaging.rtc.currentRtcSession.isDeaf) {
|
||||
this.messaging.rtc.undeafen();
|
||||
}
|
||||
} else {
|
||||
this.messaging.rtc.mute();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickMore(ev) {
|
||||
this.update({ moreMenuPopoverView: this.moreMenuPopoverView ? clear() : {} });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickRejectCall(ev) {
|
||||
if (this.thread.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
await this.thread.leaveCall();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickScreen(ev) {
|
||||
this.messaging.rtc.toggleScreenShare();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickToggleAudioCall(ev) {
|
||||
if (this.thread.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
await this.thread.toggleCall();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickToggleVideoCall(ev) {
|
||||
if (this.thread.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
await this.thread.toggleCall({
|
||||
startWithVideo: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
callButtonTitle: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
if (this.thread.rtc) {
|
||||
return this.env._t("Disconnect");
|
||||
} else {
|
||||
return this.env._t("Join Call");
|
||||
}
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
callMainView: one('CallMainView', {
|
||||
identifying: true,
|
||||
inverse: 'callActionListView',
|
||||
}),
|
||||
callView: one('CallView', {
|
||||
related: 'callMainView.callView',
|
||||
required: true,
|
||||
}),
|
||||
cameraButtonTitle: attr({
|
||||
compute() {
|
||||
if (this.messaging.rtc.sendUserVideo) {
|
||||
return this.env._t("Stop camera");
|
||||
} else {
|
||||
return this.env._t("Turn camera on");
|
||||
}
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
headphoneButtonTitle: attr({
|
||||
compute() {
|
||||
if (!this.messaging.rtc.currentRtcSession) {
|
||||
return clear();
|
||||
}
|
||||
if (this.messaging.rtc.currentRtcSession.isDeaf) {
|
||||
return this.env._t("Undeafen");
|
||||
} else {
|
||||
return this.env._t("Deafen");
|
||||
}
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
isSmall: attr({
|
||||
compute() {
|
||||
return Boolean(this.callView && this.callView.threadView.compact && !this.callView.isFullScreen);
|
||||
},
|
||||
}),
|
||||
microphoneButtonTitle: attr({
|
||||
compute() {
|
||||
if (!this.messaging.rtc.currentRtcSession) {
|
||||
return clear();
|
||||
}
|
||||
if (this.messaging.rtc.currentRtcSession.isMute) {
|
||||
return this.env._t("Unmute");
|
||||
} else {
|
||||
return this.env._t("Mute");
|
||||
}
|
||||
},
|
||||
}),
|
||||
moreButtonRef: attr(),
|
||||
moreMenuPopoverView: one('PopoverView', {
|
||||
inverse: 'callActionListViewOwnerAsMoreMenu',
|
||||
}),
|
||||
screenSharingButtonTitle: attr({
|
||||
compute() {
|
||||
if (this.messaging.rtc.sendDisplay) {
|
||||
return this.env._t("Stop screen sharing");
|
||||
} else {
|
||||
return this.env._t("Share screen");
|
||||
}
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
related: 'callMainView.thread',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
|
||||
registerModel({
|
||||
name: 'CallDemoView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Stops recording user's microphone.
|
||||
*/
|
||||
disableMicrophone() {
|
||||
this.audioRef.el.srcObject = null;
|
||||
if (!this.audioStream) {
|
||||
return;
|
||||
}
|
||||
this.stopTracksOnMediaStream(this.audioStream);
|
||||
this.update({ audioStream: null });
|
||||
},
|
||||
/**
|
||||
* Stops recording user's video device.
|
||||
*/
|
||||
disableVideo() {
|
||||
this.videoRef.el.srcObject = null;
|
||||
if (!this.videoStream) {
|
||||
return;
|
||||
}
|
||||
this.stopTracksOnMediaStream(this.videoStream);
|
||||
this.update({ videoStream: null });
|
||||
},
|
||||
/**
|
||||
* Asks for access to the user's microphone if not granted yet, then
|
||||
* starts recording and defines the resulting audio stream as the source
|
||||
* of the audio element in order to play the audio feedback.
|
||||
*/
|
||||
async enableMicrophone() {
|
||||
if (!this.doesBrowserSupportMediaDevices) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.update({ audioStream });
|
||||
this.audioRef.el.srcObject = this.audioStream;
|
||||
} catch {
|
||||
// TODO: display popup asking the user to re-enable their mic
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Asks for access to the user's video device if not granted yet, then
|
||||
* starts recording and defines the resulting video stream as the source
|
||||
* of the video element in order to display the video feedback.
|
||||
*/
|
||||
async enableVideo() {
|
||||
if (!this.doesBrowserSupportMediaDevices) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
this.update({ videoStream });
|
||||
this.videoRef.el.srcObject = this.videoStream;
|
||||
} catch {
|
||||
// TODO: display popup asking the user to re-enable their camera
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles click on the "disable microphone" button.
|
||||
*/
|
||||
onClickDisableMicrophoneButton() {
|
||||
this.disableMicrophone();
|
||||
},
|
||||
/**
|
||||
* Handles click on the "disable video" button.
|
||||
*/
|
||||
onClickDisableVideoButton() {
|
||||
this.disableVideo();
|
||||
},
|
||||
/**
|
||||
* Handles click on the "enable microphone" button.
|
||||
*/
|
||||
onClickEnableMicrophoneButton() {
|
||||
this.enableMicrophone();
|
||||
},
|
||||
/**
|
||||
* Handles click on the "enable video" button.
|
||||
*/
|
||||
onClickEnableVideoButton() {
|
||||
this.enableVideo();
|
||||
},
|
||||
/**
|
||||
* Iterates tracks of the provided MediaStream, calling the `stop`
|
||||
* method on each of them.
|
||||
*
|
||||
* @param {MediaStream} mediaStream
|
||||
*/
|
||||
stopTracksOnMediaStream(mediaStream) {
|
||||
for (const track of mediaStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Ref to the audio element used for the audio feedback.
|
||||
*/
|
||||
audioRef: attr(),
|
||||
/**
|
||||
* The MediaStream from the microphone.
|
||||
*
|
||||
* Default set to null to be consistent with the default value of
|
||||
* `HTMLMediaElement.srcObject`.
|
||||
*/
|
||||
audioStream: attr({
|
||||
default: null,
|
||||
}),
|
||||
/**
|
||||
* States whether the browser has the required APIs for
|
||||
* microphone/camera recording.
|
||||
*/
|
||||
doesBrowserSupportMediaDevices: attr({
|
||||
compute() {
|
||||
return Boolean(
|
||||
navigator.mediaDevices &&
|
||||
navigator.mediaDevices.getUserMedia &&
|
||||
window.MediaStream
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States if the user's microphone is currently recording.
|
||||
*/
|
||||
isMicrophoneEnabled: attr({
|
||||
compute() {
|
||||
return this.audioStream !== null;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States if the user's camera is currently recording.
|
||||
*/
|
||||
isVideoEnabled: attr({
|
||||
compute() {
|
||||
return this.videoStream !== null;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Ref to the video element used for the video feedback.
|
||||
*/
|
||||
videoRef: attr(),
|
||||
/**
|
||||
* The MediaStream from the camera.
|
||||
*
|
||||
* Default set to null to be consistent with the default value of
|
||||
* `HTMLMediaElement.srcObject`.
|
||||
*/
|
||||
videoStream: attr({
|
||||
default: null,
|
||||
}),
|
||||
/**
|
||||
* States the welcome view containing this media preview.
|
||||
*/
|
||||
welcomeView: one('WelcomeView', {
|
||||
identifying: true,
|
||||
inverse: 'callDemoView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'CallInviteRequestPopup',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickRefuse(ev) {
|
||||
if (this.thread.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
this.thread.leaveCall();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickAccept(ev) {
|
||||
this.thread.open();
|
||||
if (this.thread.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
await this.thread.toggleCall();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickAvatar(ev) {
|
||||
this.thread.open();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
thread: one('Thread', {
|
||||
identifying: true,
|
||||
inverse: 'callInviteRequestPopup',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import { isEventHandled, markEventHandled } from '@mail/utils/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'CallMainView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Finds a tile layout and dimensions that respects param0.aspectRatio while maximizing
|
||||
* the total area covered by the tiles within the specified container dimensions.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {number} [param0.aspectRatio]
|
||||
* @param {number} param0.containerHeight
|
||||
* @param {number} param0.containerWidth
|
||||
* @param {number} param0.tileCount
|
||||
*/
|
||||
calculateTessellation({ aspectRatio = 1, containerHeight, containerWidth, tileCount }) {
|
||||
let optimalLayout = {
|
||||
area: 0,
|
||||
cols: 0,
|
||||
tileHeight: 0,
|
||||
tileWidth: 0,
|
||||
};
|
||||
|
||||
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
|
||||
const rowCount = Math.ceil(tileCount / columnCount);
|
||||
const potentialHeight = containerWidth / (columnCount * aspectRatio);
|
||||
const potentialWidth = containerHeight / rowCount;
|
||||
let tileHeight;
|
||||
let tileWidth;
|
||||
if (potentialHeight > potentialWidth) {
|
||||
tileHeight = Math.floor(potentialWidth);
|
||||
tileWidth = Math.floor(tileHeight * aspectRatio);
|
||||
} else {
|
||||
tileWidth = Math.floor(containerWidth / columnCount);
|
||||
tileHeight = Math.floor(tileWidth / aspectRatio);
|
||||
}
|
||||
const area = tileHeight * tileWidth;
|
||||
if (area <= optimalLayout.area) {
|
||||
continue;
|
||||
}
|
||||
optimalLayout = {
|
||||
area,
|
||||
tileHeight,
|
||||
tileWidth,
|
||||
};
|
||||
}
|
||||
return optimalLayout;
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
this._showOverlay();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickHideSidebar(ev) {
|
||||
this.callView.update({ isSidebarOpen: false });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickShowSidebar(ev) {
|
||||
this.callView.update({ isSidebarOpen: true });
|
||||
},
|
||||
onComponentUpdate() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this._updateLayout();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseleave(ev) {
|
||||
if (ev.relatedTarget && ev.relatedTarget.closest('.o_CallActionList_popover')) {
|
||||
// the overlay should not be hidden when the cursor leaves to enter the controller popover
|
||||
return;
|
||||
}
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ showOverlay: false });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseMove(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (isEventHandled(ev, 'CallMainView.MouseMoveOverlay')) {
|
||||
return;
|
||||
}
|
||||
this._showOverlay();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseMoveOverlay(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
markEventHandled(ev, 'CallMainView.MouseMoveOverlay');
|
||||
this.update({
|
||||
showOverlay: true,
|
||||
showOverlayTimer: clear(),
|
||||
});
|
||||
},
|
||||
onResize() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this._updateLayout();
|
||||
},
|
||||
onShowOverlayTimeout() {
|
||||
this.update({
|
||||
showOverlay: false,
|
||||
showOverlayTimer: clear(),
|
||||
});
|
||||
},
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shows the overlay (buttons) for a set a mount of time.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_showOverlay() {
|
||||
this.update({
|
||||
showOverlay: true,
|
||||
showOverlayTimer: { doReset: this.showOverlayTimer ? true : undefined },
|
||||
});
|
||||
},
|
||||
_updateLayout() {
|
||||
if (!this.component.root.el || !this.tileContainerRef.el) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = this.tileContainerRef.el.getBoundingClientRect();
|
||||
const { tileWidth, tileHeight } = this.calculateTessellation({
|
||||
aspectRatio: this.callView.aspectRatio,
|
||||
containerHeight: height,
|
||||
containerWidth: width,
|
||||
tileCount: this.tileContainerRef.el.children.length,
|
||||
});
|
||||
this.update({
|
||||
tileHeight,
|
||||
tileWidth,
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* The model for the controller (buttons).
|
||||
*/
|
||||
callActionListView: one('CallActionListView', {
|
||||
default: {},
|
||||
inverse: 'callMainView',
|
||||
readonly: true,
|
||||
}),
|
||||
callView: one('CallView', {
|
||||
identifying: true,
|
||||
inverse: 'callMainView',
|
||||
}),
|
||||
component: attr(),
|
||||
hasSidebarButton: attr({
|
||||
compute() {
|
||||
return Boolean(this.callView.activeRtcSession && this.showOverlay && !this.callView.threadView.compact);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if the controller is an overlay or a bottom bar.
|
||||
*/
|
||||
isControllerFloating: attr({
|
||||
compute() {
|
||||
return Boolean(this.callView.isFullScreen || this.callView.activeRtcSession && !this.callView.threadView.compact);
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
mainTiles: many('CallMainViewTile', {
|
||||
compute() {
|
||||
if (this.callView.activeRtcSession) {
|
||||
return [{ channelMember: this.callView.activeRtcSession.channelMember }];
|
||||
}
|
||||
return this.callView.filteredChannelMembers.map(channelMember => ({ channelMember }));
|
||||
},
|
||||
inverse: 'callMainViewOwner',
|
||||
}),
|
||||
/**
|
||||
* Determines if we show the overlay with the control buttons.
|
||||
*/
|
||||
showOverlay: attr({
|
||||
default: true,
|
||||
}),
|
||||
showOverlayTimer: one('Timer', {
|
||||
inverse: 'callMainViewAsShowOverlay',
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
related: 'callView.thread',
|
||||
required: true,
|
||||
}),
|
||||
tileContainerRef: attr(),
|
||||
tileHeight: attr({
|
||||
default: 0,
|
||||
}),
|
||||
tileWidth: attr({
|
||||
default: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'CallMainViewTile',
|
||||
fields: {
|
||||
callMainViewOwner: one('CallMainView', {
|
||||
identifying: true,
|
||||
inverse: 'mainTiles',
|
||||
}),
|
||||
channelMember: one('ChannelMember', {
|
||||
identifying: true,
|
||||
}),
|
||||
participantCard: one('CallParticipantCard', {
|
||||
default: {},
|
||||
inverse: 'mainViewTileOwner',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'CallOptionMenu',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Creates and download a file that contains the logs of the current RTC call.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickDownloadLogs(ev) {
|
||||
const channel = this.callActionListView.thread;
|
||||
if (!channel.rtc) {
|
||||
return;
|
||||
}
|
||||
const data = window.JSON.stringify(channel.rtc.logs);
|
||||
const blob = new window.Blob([data], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `RtcLogs_Channel${channel.id}_Session${channel.rtc.currentRtcSession.id}_${window.moment().format('YYYY-MM-DD_HH-mm')}.json`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* States the OWL component of this option list.
|
||||
*/
|
||||
component: attr(),
|
||||
callActionListView: one('CallActionListView', {
|
||||
related: 'popoverViewOwner.callActionListViewOwnerAsMoreMenu',
|
||||
}),
|
||||
callView: one('CallView', {
|
||||
related: 'callActionListView.callView',
|
||||
required: true,
|
||||
}),
|
||||
popoverViewOwner: one('PopoverView', {
|
||||
identifying: true,
|
||||
inverse: 'callOptionMenuView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { isEventHandled, markEventHandled } from '@mail/utils/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'CallParticipantCard',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClick(ev) {
|
||||
if (isEventHandled(ev, 'CallParticipantCard.clickVolumeAnchor')) {
|
||||
return;
|
||||
}
|
||||
if (this.rtcSession) {
|
||||
if (this.callView.activeRtcSession === this.rtcSession && this.mainViewTileOwner) {
|
||||
this.callView.channel.update({ activeRtcSession: clear() });
|
||||
} else {
|
||||
this.callView.channel.update({ activeRtcSession: this.rtcSession });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const channel = this.channelMember.channel.thread;
|
||||
const channelData = await this.messaging.rpc(({
|
||||
route: '/mail/rtc/channel/cancel_call_invitation',
|
||||
params: {
|
||||
channel_id: channel.id,
|
||||
member_ids: [this.channelMember.id],
|
||||
},
|
||||
}));
|
||||
if (!channel.exists()) {
|
||||
return;
|
||||
}
|
||||
channel.update(channelData);
|
||||
},
|
||||
/**
|
||||
* Handled by the popover component.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickVolumeAnchor(ev) {
|
||||
markEventHandled(ev, 'CallParticipantCard.clickVolumeAnchor');
|
||||
this.update({ callParticipantCardPopoverView: this.callParticipantCardPopoverView ? clear() : {} });
|
||||
},
|
||||
/**
|
||||
* This listens to the right click event, and used to redirect the event
|
||||
* as a click on the popover.
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
async onContextMenu(ev) {
|
||||
ev.preventDefault();
|
||||
if (!this.volumeMenuAnchorRef || !this.volumeMenuAnchorRef.el) {
|
||||
return;
|
||||
}
|
||||
this.volumeMenuAnchorRef.el.click();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
callParticipantCardPopoverView: one('PopoverView', {
|
||||
inverse: 'callParticipantCardOwner',
|
||||
}),
|
||||
channelMember: one('ChannelMember', {
|
||||
compute() {
|
||||
if (this.sidebarViewTileOwner) {
|
||||
return this.sidebarViewTileOwner.channelMember;
|
||||
}
|
||||
return this.mainViewTileOwner.channelMember;
|
||||
},
|
||||
inverse: 'callParticipantCards',
|
||||
}),
|
||||
mainViewTileOwner: one('CallMainViewTile', {
|
||||
identifying: true,
|
||||
inverse: 'participantCard',
|
||||
}),
|
||||
/**
|
||||
* Determines if this card has to be displayed in a minimized form.
|
||||
*/
|
||||
isMinimized: attr({
|
||||
compute() {
|
||||
return Boolean(this.callView && this.callView.isMinimized);
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines if the rtcSession is in a valid "talking" state.
|
||||
*/
|
||||
isTalking: attr({
|
||||
compute() {
|
||||
return Boolean(this.rtcSession && this.rtcSession.isTalking && !this.rtcSession.isMute);
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* The callView that displays this card.
|
||||
*/
|
||||
callView: one('CallView', {
|
||||
compute() {
|
||||
if (this.sidebarViewTileOwner) {
|
||||
return this.sidebarViewTileOwner.callSidebarViewOwner.callView;
|
||||
}
|
||||
return this.mainViewTileOwner.callMainViewOwner.callView;
|
||||
},
|
||||
inverse: 'participantCards',
|
||||
}),
|
||||
rtcSession: one('RtcSession', {
|
||||
related: 'channelMember.rtcSession',
|
||||
inverse: 'callParticipantCards',
|
||||
}),
|
||||
sidebarViewTileOwner: one('CallSidebarViewTile', {
|
||||
identifying: true,
|
||||
inverse: 'participantCard',
|
||||
}),
|
||||
callParticipantVideoView: one('CallParticipantVideoView', {
|
||||
compute() {
|
||||
if (this.rtcSession && this.rtcSession.videoStream) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'callParticipantCardOwner',
|
||||
}),
|
||||
volumeMenuAnchorRef: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'CallParticipantCardPopoverContentView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onChangeVolume(ev) {
|
||||
this.callParticipantCard.rtcSession && this.callParticipantCard.rtcSession.setVolume(parseFloat(ev.target.value));
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
callParticipantCard: one('CallParticipantCard', {
|
||||
related: 'popoverViewOwner.callParticipantCardOwner',
|
||||
}),
|
||||
/**
|
||||
* Determines whether or not we show the connection info.
|
||||
*/
|
||||
hasConnectionInfo: attr({
|
||||
compute() {
|
||||
return Boolean(this.callParticipantCard.rtcSession && this.env.debug && this.callParticipantCard.channelMember.channel.thread.rtc);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* The text describing the inbound ice connection candidate type.
|
||||
*/
|
||||
inboundConnectionTypeText: attr({
|
||||
compute() {
|
||||
if (!this.callParticipantCard.rtcSession || !this.callParticipantCard.rtcSession.remoteCandidateType) {
|
||||
return sprintf(this.env._t('From %s: no connection'), this.callParticipantCard.channelMember.persona.name);
|
||||
}
|
||||
return sprintf(
|
||||
this.env._t('From %(name)s: %(candidateType)s (%(protocol)s)'), {
|
||||
candidateType: this.callParticipantCard.rtcSession.remoteCandidateType,
|
||||
name: this.callParticipantCard.channelMember.persona.name,
|
||||
protocol: this.messaging.rtc.protocolsByCandidateTypes[this.callParticipantCard.rtcSession.remoteCandidateType],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* The text describing the outbound ice connection candidate type.
|
||||
*/
|
||||
outboundConnectionTypeText: attr({
|
||||
compute() {
|
||||
if (!this.callParticipantCard.rtcSession || !this.callParticipantCard.rtcSession.localCandidateType) {
|
||||
return sprintf(this.env._t('To %s: no connection'), this.callParticipantCard.channelMember.persona.name);
|
||||
}
|
||||
return sprintf(
|
||||
this.env._t('To %(name)s: %(candidateType)s (%(protocol)s)'), {
|
||||
candidateType: this.callParticipantCard.rtcSession.localCandidateType,
|
||||
name: this.callParticipantCard.channelMember.persona.name,
|
||||
protocol: this.messaging.rtc.protocolsByCandidateTypes[this.callParticipantCard.rtcSession.localCandidateType],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
popoverViewOwner: one('PopoverView', {
|
||||
identifying: true,
|
||||
inverse: 'callParticipantCardPopoverContentView',
|
||||
})
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'CallParticipantVideoView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Since it is not possible to directly put a mediaStreamObject as the src
|
||||
* or src-object of the template, the video src is manually inserted into
|
||||
* the DOM.
|
||||
*/
|
||||
onComponentUpdate() {
|
||||
if (!this.component.root.el) {
|
||||
return;
|
||||
}
|
||||
if (!this.rtcSession || !this.rtcSession.videoStream) {
|
||||
this.component.root.el.srcObject = undefined;
|
||||
} else {
|
||||
this.component.root.el.srcObject = this.rtcSession.videoStream;
|
||||
}
|
||||
this.component.root.el.load();
|
||||
},
|
||||
/**
|
||||
* Plays the video as some browsers may not support or block autoplay.
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
async onVideoLoadedMetaData(ev) {
|
||||
try {
|
||||
await ev.target.play();
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error.name === 'NotAllowedError') {
|
||||
// Ignored as some browsers may reject play() calls that do not
|
||||
// originate from a user input.
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
callParticipantCardOwner: one('CallParticipantCard', {
|
||||
identifying: true,
|
||||
inverse: 'callParticipantVideoView',
|
||||
}),
|
||||
component: attr(),
|
||||
rtcSession: one('RtcSession', {
|
||||
compute() {
|
||||
if (this.callParticipantCardOwner.rtcSession) {
|
||||
return this.callParticipantCardOwner.rtcSession;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'CallSettingsMenu',
|
||||
identifyingMode: 'xor',
|
||||
lifecycleHooks: {
|
||||
async _created() {
|
||||
browser.addEventListener('keydown', this._onKeyDown);
|
||||
browser.addEventListener('keyup', this._onKeyUp);
|
||||
if (!this.messaging.browser.navigator.mediaDevices) {
|
||||
// zxing-js: isMediaDevicesSuported or canEnumerateDevices is false.
|
||||
this.messaging.userNotificationManager.sendNotification({
|
||||
message: this.env._t("SSL might not be set up properly"),
|
||||
title: this.env._t("Media devices unobtainable"),
|
||||
type: 'warning',
|
||||
});
|
||||
console.warn('Media devices unobtainable. SSL might not be set up properly.');
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
userDevices: await this.messaging.browser.navigator.mediaDevices.enumerateDevices()
|
||||
});
|
||||
},
|
||||
_willDelete() {
|
||||
browser.removeEventListener('keydown', this._onKeyDown);
|
||||
browser.removeEventListener('keyup', this._onKeyUp);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
onChangeBackgroundBlurAmount(ev) {
|
||||
this.userSetting.update({
|
||||
backgroundBlurAmount: Number(ev.target.value),
|
||||
});
|
||||
},
|
||||
onChangeBlur(ev) {
|
||||
this.userSetting.update({
|
||||
useBlur: !this.userSetting.useBlur,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onChangeDelay(ev) {
|
||||
this.userSetting.setDelayValue(ev.target.value);
|
||||
},
|
||||
onChangeEdgeBlurAmount(ev) {
|
||||
this.userSetting.update({
|
||||
edgeBlurAmount: Number(ev.target.value),
|
||||
});
|
||||
},
|
||||
onChangePushToTalk() {
|
||||
if (this.userSetting.usePushToTalk) {
|
||||
this.userSetting.update({
|
||||
isRegisteringKey: false,
|
||||
});
|
||||
}
|
||||
this.userSetting.togglePushToTalk();
|
||||
},
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onChangeSelectAudioInput(ev) {
|
||||
this.userSetting.setAudioInputDevice(ev.target.value);
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onChangeThreshold(ev) {
|
||||
this.userSetting.setThresholdValue(parseFloat(ev.target.value));
|
||||
},
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onChangeVideoFilterCheckbox(ev) {
|
||||
const showOnlyVideo = ev.target.checked;
|
||||
this.thread.channel.update({ showOnlyVideo });
|
||||
if (!this.callView) {
|
||||
return;
|
||||
}
|
||||
const activeRtcSession = this.callView.activeRtcSession;
|
||||
if (showOnlyVideo && activeRtcSession && !activeRtcSession.videoStream) {
|
||||
this.callView.channel.update({ activeRtcSession: clear() });
|
||||
}
|
||||
},
|
||||
onClickRegisterKeyButton() {
|
||||
this.userSetting.update({
|
||||
isRegisteringKey: !this.isRegisteringKey,
|
||||
});
|
||||
},
|
||||
_onKeyDown(ev) {
|
||||
if (!this.userSetting.isRegisteringKey) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.userSetting.setPushToTalkKey(ev);
|
||||
},
|
||||
_onKeyUp(ev) {
|
||||
if (!this.userSetting.isRegisteringKey) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.userSetting.update({
|
||||
isRegisteringKey: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
callView: one('CallView', {
|
||||
compute() {
|
||||
if (this.threadViewOwner) {
|
||||
return this.threadViewOwner.callView;
|
||||
}
|
||||
if (this.chatWindowOwner && this.chatWindowOwner.threadView) {
|
||||
return this.chatWindowOwner.threadView.callView;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
chatWindowOwner: one('ChatWindow', {
|
||||
identifying: true,
|
||||
inverse: 'callSettingsMenu',
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
compute() {
|
||||
if (this.threadViewOwner) {
|
||||
return this.threadViewOwner.thread;
|
||||
}
|
||||
if (this.chatWindowOwner) {
|
||||
return this.chatWindowOwner.thread;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
threadViewOwner: one('ThreadView', {
|
||||
identifying: true,
|
||||
inverse: 'callSettingsMenu',
|
||||
}),
|
||||
userDevices: attr({
|
||||
default: [],
|
||||
}),
|
||||
userSetting: one('UserSetting', {
|
||||
related: 'messaging.userSetting',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'CallSidebarView',
|
||||
fields: {
|
||||
callView: one('CallView', {
|
||||
identifying: true,
|
||||
inverse: 'callSidebarView',
|
||||
}),
|
||||
sidebarTiles: many('CallSidebarViewTile', {
|
||||
compute() {
|
||||
return this.callView.filteredChannelMembers.map(channelMember => ({ channelMember }));
|
||||
},
|
||||
inverse: 'callSidebarViewOwner',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'CallSidebarViewTile',
|
||||
fields: {
|
||||
callSidebarViewOwner: one('CallSidebarView', {
|
||||
identifying: true,
|
||||
inverse: 'sidebarTiles',
|
||||
}),
|
||||
channelMember: one('ChannelMember', {
|
||||
identifying: true,
|
||||
}),
|
||||
participantCard: one('CallParticipantCard', {
|
||||
default: {},
|
||||
inverse: 'sidebarViewTileOwner',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'CallSystrayMenu',
|
||||
fields: {
|
||||
buttonTitle: attr({
|
||||
compute() {
|
||||
if (!this.messaging.rtc.channel) {
|
||||
return clear();
|
||||
}
|
||||
return sprintf(
|
||||
this.env._t("Open conference: %s"),
|
||||
this.messaging.rtc.channel.displayName,
|
||||
);
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
rtc: one('Rtc', {
|
||||
identifying: true,
|
||||
inverse: 'callSystrayMenu',
|
||||
}),
|
||||
},
|
||||
});
|
||||
222
odoo-bringout-oca-ocb-mail/mail/static/src/models/call_view.js
Normal file
222
odoo-bringout-oca-ocb-mail/mail/static/src/models/call_view.js
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'CallView',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
browser.addEventListener('fullscreenchange', this._onFullScreenChange);
|
||||
},
|
||||
_willDelete() {
|
||||
browser.removeEventListener('fullscreenchange', this._onFullScreenChange);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onRtcSettingsDialogClosed(ev) {
|
||||
this.messaging.userSetting.callSettingsMenu.toggle();
|
||||
},
|
||||
async activateFullScreen() {
|
||||
const el = document.body;
|
||||
try {
|
||||
if (el.requestFullscreen) {
|
||||
await el.requestFullscreen();
|
||||
} else if (el.mozRequestFullScreen) {
|
||||
await el.mozRequestFullScreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
await el.webkitRequestFullscreen();
|
||||
}
|
||||
if (this.exists()) {
|
||||
this.update({ isFullScreen: true });
|
||||
}
|
||||
} catch (_e) {
|
||||
if (this.exists()) {
|
||||
this.update({ isFullScreen: false });
|
||||
}
|
||||
this.messaging.notify({
|
||||
message: this.env._t("The FullScreen mode was denied by the browser"),
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
},
|
||||
async deactivateFullScreen() {
|
||||
const fullScreenElement = document.webkitFullscreenElement || document.fullscreenElement;
|
||||
if (fullScreenElement) {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
await document.mozCancelFullScreen();
|
||||
} else if (document.webkitCancelFullScreen) {
|
||||
await document.webkitCancelFullScreen();
|
||||
}
|
||||
}
|
||||
if (this.exists()) {
|
||||
this.update({ isFullScreen: false });
|
||||
}
|
||||
},
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeRtcChannel() {
|
||||
this.deactivateFullScreen();
|
||||
if (!this.thread && !this.thread.rtc) {
|
||||
this.channel.update({ showOnlyVideo: false });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeVideoCount() {
|
||||
if (this.thread.videoCount === 0) {
|
||||
this.channel.update({ showOnlyVideo: false });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onFullScreenChange() {
|
||||
const fullScreenElement = document.webkitFullscreenElement || document.fullscreenElement;
|
||||
if (fullScreenElement) {
|
||||
this.update({ isFullScreen: true });
|
||||
return;
|
||||
}
|
||||
this.update({ isFullScreen: false });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* The rtc session that is the main card of the view.
|
||||
*/
|
||||
activeRtcSession: one('RtcSession', {
|
||||
related: 'channel.activeRtcSession',
|
||||
}),
|
||||
/**
|
||||
* The aspect ratio of the tiles.
|
||||
*/
|
||||
aspectRatio: attr({
|
||||
compute() {
|
||||
const rtcAspectRatio = this.messaging.rtc.videoConfig && this.messaging.rtc.videoConfig.aspectRatio;
|
||||
const aspectRatio = rtcAspectRatio || 16 / 9;
|
||||
// if we are in minimized mode (round avatar frames), we treat the cards like squares.
|
||||
return this.isMinimized ? 1 : aspectRatio;
|
||||
},
|
||||
default: 16 / 9,
|
||||
}),
|
||||
callMainView: one('CallMainView', {
|
||||
default: {},
|
||||
inverse: 'callView',
|
||||
readonly: true,
|
||||
}),
|
||||
callSidebarView: one('CallSidebarView', {
|
||||
compute() {
|
||||
if (this.activeRtcSession && this.isSidebarOpen && !this.threadView.compact) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'callView',
|
||||
}),
|
||||
channel: one('Channel', {
|
||||
related: 'thread.channel',
|
||||
}),
|
||||
filteredChannelMembers: many('ChannelMember', {
|
||||
compute() {
|
||||
if (!this.channel) {
|
||||
return clear();
|
||||
}
|
||||
const channelMembers = [];
|
||||
for (const channelMember of this.channel.callParticipants) {
|
||||
if (this.channel.showOnlyVideo && this.thread.videoCount > 0 && !channelMember.isStreaming) {
|
||||
continue;
|
||||
}
|
||||
channelMembers.push(channelMember);
|
||||
}
|
||||
return channelMembers;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines if the viewer should be displayed fullScreen.
|
||||
*/
|
||||
isFullScreen: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines if the tiles are in a minimized format:
|
||||
* small circles instead of cards, smaller display area.
|
||||
*/
|
||||
isMinimized: attr({
|
||||
compute() {
|
||||
if (!this.threadView || !this.thread) {
|
||||
return true;
|
||||
}
|
||||
if (this.isFullScreen || this.threadView.compact) {
|
||||
return false;
|
||||
}
|
||||
if (this.activeRtcSession) {
|
||||
return false;
|
||||
}
|
||||
return !this.thread.rtc || this.thread.videoCount === 0;
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
isSidebarOpen: attr({
|
||||
default: true,
|
||||
}),
|
||||
/**
|
||||
* Text content that is displayed on title of the layout settings dialog.
|
||||
*/
|
||||
layoutSettingsTitle: attr({
|
||||
compute() {
|
||||
return this.env._t("Change Layout");
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* All the participant cards of the call viewer (main card and tile cards).
|
||||
* this is a technical inverse to distinguish from the other relation 'tileParticipantCards'.
|
||||
*/
|
||||
participantCards: many('CallParticipantCard', {
|
||||
inverse: 'callView',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* Text content that is displayed on title of the settings dialog.
|
||||
*/
|
||||
settingsTitle: attr({
|
||||
compute() {
|
||||
return this.env._t("Settings");
|
||||
},
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
related: 'threadView.thread',
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* ThreadView on which the call view is attached.
|
||||
*/
|
||||
threadView: one('ThreadView', {
|
||||
identifying: true,
|
||||
inverse: 'callView',
|
||||
}),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['thread.rtc'],
|
||||
methodName: '_onChangeRtcChannel',
|
||||
},
|
||||
{
|
||||
dependencies: ['thread.videoCount'],
|
||||
methodName: '_onChangeVideoCount',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { cleanSearchTerm } from '@mail/utils/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'CannedResponse',
|
||||
modelMethods: {
|
||||
/**
|
||||
* Fetches canned responses matching the given search term to extend the
|
||||
* JS knowledge and to update the suggestion list accordingly.
|
||||
*
|
||||
* In practice all canned responses are already fetched at init so this
|
||||
* method does nothing.
|
||||
*
|
||||
* @param {string} searchTerm
|
||||
* @param {Object} [options={}]
|
||||
* @param {Thread} [options.thread] prioritize and/or restrict
|
||||
* result in the context of given thread
|
||||
*/
|
||||
fetchSuggestions(searchTerm, { thread } = {}) {},
|
||||
/**
|
||||
* Returns a sort function to determine the order of display of canned
|
||||
* responses in the suggestion list.
|
||||
*
|
||||
* @param {string} searchTerm
|
||||
* @param {Object} [options={}]
|
||||
* @param {Thread} [options.thread] prioritize result in the
|
||||
* context of given thread
|
||||
* @returns {function}
|
||||
*/
|
||||
getSuggestionSortFunction(searchTerm, { thread } = {}) {
|
||||
const cleanedSearchTerm = cleanSearchTerm(searchTerm);
|
||||
return (a, b) => {
|
||||
const cleanedAName = cleanSearchTerm(a.source || '');
|
||||
const cleanedBName = cleanSearchTerm(b.source || '');
|
||||
if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
|
||||
return -1;
|
||||
}
|
||||
if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
|
||||
return 1;
|
||||
}
|
||||
if (cleanedAName < cleanedBName) {
|
||||
return -1;
|
||||
}
|
||||
if (cleanedAName > cleanedBName) {
|
||||
return 1;
|
||||
}
|
||||
return a.id - b.id;
|
||||
};
|
||||
},
|
||||
/*
|
||||
* Returns canned responses that match the given search term.
|
||||
*
|
||||
* @static
|
||||
* @param {string} searchTerm
|
||||
* @param {Object} [options={}]
|
||||
* @param {Thread} [options.thread] prioritize and/or restrict
|
||||
* result in the context of given thread
|
||||
* @returns {[CannedResponse[], CannedResponse[]]}
|
||||
*/
|
||||
searchSuggestions(searchTerm, { thread } = {}) {
|
||||
const cleanedSearchTerm = cleanSearchTerm(searchTerm);
|
||||
return [this.messaging.cannedResponses.filter(cannedResponse =>
|
||||
cleanSearchTerm(cannedResponse.source).includes(cleanedSearchTerm)
|
||||
)];
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
/**
|
||||
* The keyword to use a specific canned response.
|
||||
*/
|
||||
source: attr(),
|
||||
/**
|
||||
* The canned response itself which will replace the keyword previously
|
||||
* entered.
|
||||
*/
|
||||
substitution: attr(),
|
||||
suggestable: one('ComposerSuggestable', {
|
||||
default: {},
|
||||
inverse: 'cannedResponse',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
279
odoo-bringout-oca-ocb-mail/mail/static/src/models/channel.js
Normal file
279
odoo-bringout-oca-ocb-mail/mail/static/src/models/channel.js
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one, many } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'Channel',
|
||||
modelMethods: {
|
||||
/**
|
||||
* Performs the `channel_get` RPC on `mail.channel`.
|
||||
*
|
||||
* `openChat` is preferable in business code because it will avoid the
|
||||
* RPC if the chat already exists.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {integer[]} param0.partnerIds
|
||||
* @param {boolean} [param0.pinForCurrentPartner]
|
||||
* @returns {Channel|undefined} the created or existing chat
|
||||
*/
|
||||
async performRpcCreateChat({ partnerIds, pinForCurrentPartner }) {
|
||||
// TODO FIX: potential duplicate chat task-2276490
|
||||
const data = await this.messaging.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_get',
|
||||
kwargs: {
|
||||
partners_to: partnerIds,
|
||||
pin: pinForCurrentPartner,
|
||||
},
|
||||
});
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const { channel } = this.messaging.models['Thread'].insert(
|
||||
this.messaging.models['Thread'].convertData(data)
|
||||
);
|
||||
return channel;
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
async fetchChannelMembers() {
|
||||
const channelData = await this.messaging.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'load_more_members',
|
||||
args: [[this.id]],
|
||||
kwargs: {
|
||||
known_member_ids: this.channelMembers.map(channelMember => channelMember.id),
|
||||
},
|
||||
});
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update(channelData);
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activeRtcSession: one('RtcSession'),
|
||||
areAllMembersLoaded: attr({
|
||||
compute() {
|
||||
return this.memberCount === this.channelMembers.length;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Cache key to force a reload of the avatar when avatar is changed.
|
||||
*/
|
||||
avatarCacheKey: attr(),
|
||||
callParticipants: many('ChannelMember', {
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
const callParticipants = this.thread.invitedMembers;
|
||||
for (const rtcSession of this.thread.rtcSessions) {
|
||||
callParticipants.push(rtcSession.channelMember);
|
||||
}
|
||||
return callParticipants;
|
||||
},
|
||||
sort: [
|
||||
['truthy-first', 'rtcSession'],
|
||||
['smaller-first', 'rtcSession.id'],
|
||||
],
|
||||
}),
|
||||
channelMembers: many('ChannelMember', {
|
||||
inverse: 'channel',
|
||||
isCausal: true,
|
||||
}),
|
||||
channelPreviewViews: many('ChannelPreviewView', {
|
||||
inverse: 'channel',
|
||||
}),
|
||||
channel_type: attr(),
|
||||
correspondent: one('Partner', {
|
||||
compute() {
|
||||
if (this.channel_type === 'channel') {
|
||||
return clear();
|
||||
}
|
||||
const correspondents = this.channelMembers
|
||||
.filter(member => member.persona && member.persona.partner && !member.isMemberOfCurrentUser)
|
||||
.map(member => member.persona.partner);
|
||||
if (correspondents.length === 1) {
|
||||
// 2 members chat
|
||||
return correspondents[0];
|
||||
}
|
||||
const partners = this.channelMembers
|
||||
.filter(member => member.persona && member.persona.partner)
|
||||
.map(member => member.persona.partner);
|
||||
if (partners.length === 1) {
|
||||
// chat with oneself
|
||||
return partners[0];
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
correspondentOfDmChat: one('Partner', {
|
||||
compute() {
|
||||
if (
|
||||
this.channel_type === 'chat' &&
|
||||
this.correspondent
|
||||
) {
|
||||
return this.correspondent;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'dmChatWithCurrentPartner',
|
||||
}),
|
||||
custom_channel_name: attr(),
|
||||
/**
|
||||
* Useful to compute `discussSidebarCategoryItem`.
|
||||
*/
|
||||
discussSidebarCategory: one('DiscussSidebarCategory', {
|
||||
compute() {
|
||||
switch (this.channel_type) {
|
||||
case 'channel':
|
||||
return this.messaging.discuss.categoryChannel;
|
||||
case 'chat':
|
||||
case 'group':
|
||||
return this.messaging.discuss.categoryChat;
|
||||
default:
|
||||
return clear();
|
||||
}
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines the discuss sidebar category item that displays this
|
||||
* channel.
|
||||
*/
|
||||
discussSidebarCategoryItem: one('DiscussSidebarCategoryItem', {
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.thread.isPinned) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.discussSidebarCategory) {
|
||||
return clear();
|
||||
}
|
||||
return { category: this.discussSidebarCategory };
|
||||
},
|
||||
inverse: 'channel',
|
||||
}),
|
||||
displayName: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return;
|
||||
}
|
||||
if (this.channel_type === 'chat' && this.correspondent) {
|
||||
return this.custom_channel_name || this.thread.getMemberName(this.correspondent.persona);
|
||||
}
|
||||
if (this.channel_type === 'group' && !this.thread.name) {
|
||||
return this.channelMembers
|
||||
.filter(channelMember => channelMember.persona)
|
||||
.map(channelMember => this.thread.getMemberName(channelMember.persona))
|
||||
.join(this.env._t(", "));
|
||||
}
|
||||
return this.thread.name;
|
||||
},
|
||||
}),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
/**
|
||||
* Local value of message unread counter, that means it is based on
|
||||
* initial server value and updated with interface updates.
|
||||
*/
|
||||
localMessageUnreadCounter: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
// By default trust the server up to the last message it used
|
||||
// because it's not possible to do better.
|
||||
let baseCounter = this.serverMessageUnreadCounter;
|
||||
let countFromId = this.thread.serverLastMessage ? this.thread.serverLastMessage.id : 0;
|
||||
// But if the client knows the last seen message that the server
|
||||
// returned (and by assumption all the messages that come after),
|
||||
// the counter can be computed fully locally, ignoring potentially
|
||||
// obsolete values from the server.
|
||||
const firstMessage = this.thread.orderedMessages[0];
|
||||
if (
|
||||
firstMessage &&
|
||||
this.thread.lastSeenByCurrentPartnerMessageId &&
|
||||
this.thread.lastSeenByCurrentPartnerMessageId >= firstMessage.id
|
||||
) {
|
||||
baseCounter = 0;
|
||||
countFromId = this.thread.lastSeenByCurrentPartnerMessageId;
|
||||
}
|
||||
// Include all the messages that are known locally but the server
|
||||
// didn't take into account.
|
||||
return this.thread.orderedMessages.reduce((total, message) => {
|
||||
if (message.id <= countFromId) {
|
||||
return total;
|
||||
}
|
||||
return total + 1;
|
||||
}, baseCounter);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States the number of members in this channel according to the server.
|
||||
*/
|
||||
memberCount: attr(),
|
||||
memberOfCurrentUser: one('ChannelMember', {
|
||||
inverse: 'channelAsMemberOfCurrentUser',
|
||||
}),
|
||||
orderedOfflineMembers: many('ChannelMember', {
|
||||
inverse: 'channelAsOfflineMember',
|
||||
sort: [
|
||||
['truthy-first', 'persona.name'],
|
||||
['case-insensitive-asc', 'persona.name'],
|
||||
],
|
||||
}),
|
||||
orderedOnlineMembers: many('ChannelMember', {
|
||||
inverse: 'channelAsOnlineMember',
|
||||
sort: [
|
||||
['truthy-first', 'persona.name'],
|
||||
['case-insensitive-asc', 'persona.name'],
|
||||
],
|
||||
}),
|
||||
/**
|
||||
* Message unread counter coming from server.
|
||||
*
|
||||
* Value of this field is unreliable, due to dynamic nature of
|
||||
* messaging. So likely outdated/unsync with server. Should use
|
||||
* localMessageUnreadCounter instead, which smartly guess the actual
|
||||
* message unread counter at all time.
|
||||
*
|
||||
* @see localMessageUnreadCounter
|
||||
*/
|
||||
serverMessageUnreadCounter: attr({
|
||||
default: 0,
|
||||
}),
|
||||
/**
|
||||
* Determines whether we only display the participants who broadcast a video or all of them.
|
||||
*/
|
||||
showOnlyVideo: attr({
|
||||
default: false,
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
compute() {
|
||||
return {
|
||||
id: this.id,
|
||||
model: 'mail.channel',
|
||||
};
|
||||
},
|
||||
inverse: 'channel',
|
||||
isCausal: true,
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* States how many members are currently unknown on the client side.
|
||||
* This is the difference between the total number of members of the
|
||||
* channel as reported in memberCount and those actually in members.
|
||||
*/
|
||||
unknownMemberCount: attr({
|
||||
compute() {
|
||||
return this.memberCount - this.channelMembers.length;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { cleanSearchTerm } from '@mail/utils/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelCommand',
|
||||
modelMethods: {
|
||||
/**
|
||||
* Fetches channel commands matching the given search term to extend the
|
||||
* JS knowledge and to update the suggestion list accordingly.
|
||||
*
|
||||
* In practice all channel commands are already fetched at init so this
|
||||
* method does nothing.
|
||||
*
|
||||
* @param {string} searchTerm
|
||||
* @param {Object} [options={}]
|
||||
* @param {Thread} [options.thread] prioritize and/or restrict
|
||||
* result in the context of given thread
|
||||
*/
|
||||
fetchSuggestions(searchTerm, { thread } = {}) {},
|
||||
/**
|
||||
* Returns a sort function to determine the order of display of channel
|
||||
* commands in the suggestion list.
|
||||
*
|
||||
* @param {string} searchTerm
|
||||
* @param {Object} [options={}]
|
||||
* @param {Thread} [options.thread] prioritize result in the
|
||||
* context of given thread
|
||||
* @returns {function}
|
||||
*/
|
||||
getSuggestionSortFunction(searchTerm, { thread } = {}) {
|
||||
const cleanedSearchTerm = cleanSearchTerm(searchTerm);
|
||||
return (a, b) => {
|
||||
const isATypeSpecific = a.channel_types;
|
||||
const isBTypeSpecific = b.channel_types;
|
||||
if (isATypeSpecific && !isBTypeSpecific) {
|
||||
return -1;
|
||||
}
|
||||
if (!isATypeSpecific && isBTypeSpecific) {
|
||||
return 1;
|
||||
}
|
||||
const cleanedAName = cleanSearchTerm(a.name || '');
|
||||
const cleanedBName = cleanSearchTerm(b.name || '');
|
||||
if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
|
||||
return -1;
|
||||
}
|
||||
if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
|
||||
return 1;
|
||||
}
|
||||
if (cleanedAName < cleanedBName) {
|
||||
return -1;
|
||||
}
|
||||
if (cleanedAName > cleanedBName) {
|
||||
return 1;
|
||||
}
|
||||
return a.id - b.id;
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Returns channel commands that match the given search term.
|
||||
*
|
||||
* @param {string} searchTerm
|
||||
* @param {Object} [options={}]
|
||||
* @param {Thread} [options.thread] prioritize and/or restrict
|
||||
* result in the context of given thread
|
||||
* @returns {[ChannelCommand[], ChannelCommand[]]}
|
||||
*/
|
||||
searchSuggestions(searchTerm, { thread } = {}) {
|
||||
if (!thread.channel) {
|
||||
// channel commands are channel specific
|
||||
return [[]];
|
||||
}
|
||||
const cleanedSearchTerm = cleanSearchTerm(searchTerm);
|
||||
return [this.messaging.commands.filter(command => {
|
||||
if (!cleanSearchTerm(command.name).includes(cleanedSearchTerm)) {
|
||||
return false;
|
||||
}
|
||||
if (command.channel_types) {
|
||||
return command.channel_types.includes(thread.channel.channel_type);
|
||||
}
|
||||
return true;
|
||||
})];
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* Executes this command on the given `mail.channel`.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {Thread} param0.channel
|
||||
* @param {Object} [param0.body='']
|
||||
*/
|
||||
async execute({ channel, body = '' }) {
|
||||
return this.messaging.rpc({
|
||||
model: 'mail.channel',
|
||||
method: this.methodName,
|
||||
args: [[channel.id]],
|
||||
kwargs: { body },
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Determines on which channel types `this` is available.
|
||||
* Type of the channel (e.g. 'chat', 'channel' or 'groups')
|
||||
* This field should contain an array when filtering is desired.
|
||||
* Otherwise, it should be undefined when all types are allowed.
|
||||
*/
|
||||
channel_types: attr(),
|
||||
/**
|
||||
* The command that will be executed.
|
||||
*/
|
||||
help: attr({
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* Name of the method of `mail.channel` to call on the server when
|
||||
* executing this command.
|
||||
*/
|
||||
methodName: attr({
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* The keyword to use a specific command.
|
||||
*/
|
||||
name: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
suggestable: one('ComposerSuggestable', {
|
||||
default: {},
|
||||
inverse: 'channelCommand',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear, link, unlink } from '@mail/model/model_field_command';
|
||||
import { cleanSearchTerm } from '@mail/utils/utils';
|
||||
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelInvitationForm',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Handles click on the "copy" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickCopy(ev) {
|
||||
await navigator.clipboard.writeText(this.thread.invitationLink);
|
||||
this.messaging.notify({
|
||||
message: this.env._t('Link copied!'),
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Handles click on the "invite" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickInvite(ev) {
|
||||
if (this.thread.channel.channel_type === 'chat') {
|
||||
const partners_to = [...new Set([
|
||||
this.messaging.currentPartner.id,
|
||||
...this.thread.channel.channelMembers.filter(member => member.persona && member.persona.partner).map(member => member.persona.partner.id),
|
||||
...this.selectedPartners.map(partner => partner.id),
|
||||
])];
|
||||
const channel = await this.messaging.models['Thread'].createGroupChat({ partners_to });
|
||||
if (this.thread.rtc) {
|
||||
/**
|
||||
* if we were in a RTC call on the current thread, we move to the new group chat.
|
||||
* A smoother transfer would be moving the RTC sessions from one channel to
|
||||
* the other (server-side too), but it would be considerably more complex.
|
||||
*/
|
||||
await channel.toggleCall({
|
||||
startWithVideo: !!this.thread.rtc.videoTrack,
|
||||
videoType: this.thread.rtc.sendUserVideo ? 'user-video' : 'display',
|
||||
});
|
||||
}
|
||||
if (channel.exists()) {
|
||||
channel.open();
|
||||
}
|
||||
} else {
|
||||
await this.messaging.rpc(({
|
||||
model: 'mail.channel',
|
||||
method: 'add_members',
|
||||
args: [[this.thread.id]],
|
||||
kwargs: {
|
||||
partner_ids: this.selectedPartners.map(partner => partner.id),
|
||||
invite_to_rtc_call: !!this.thread.rtc,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (this.exists()) {
|
||||
this.delete();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Partner} partner
|
||||
*/
|
||||
onClickSelectablePartner(partner) {
|
||||
if (this.selectedPartners.includes(partner)) {
|
||||
this.update({ selectedPartners: unlink(partner) });
|
||||
return;
|
||||
}
|
||||
this.update({ selectedPartners: link(partner) });
|
||||
},
|
||||
/**
|
||||
* @param {Partner} partner
|
||||
*/
|
||||
onClickSelectedPartner(partner) {
|
||||
this.update({ selectedPartners: unlink(partner) });
|
||||
},
|
||||
/**
|
||||
* Handles OWL update on this channel invitation form component.
|
||||
*/
|
||||
onComponentUpdate() {
|
||||
if (this.doFocusOnSearchInput && this.searchInputRef.el) {
|
||||
this.searchInputRef.el.focus();
|
||||
this.searchInputRef.el.setSelectionRange(this.searchTerm.length, this.searchTerm.length);
|
||||
this.update({ doFocusOnSearchInput: clear() });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles focus on the invitation link.
|
||||
*/
|
||||
onFocusInvitationLinkInput(ev) {
|
||||
ev.target.select();
|
||||
},
|
||||
/**
|
||||
* @param {Partner} partner
|
||||
* @param {InputEvent} ev
|
||||
*/
|
||||
onInputPartnerCheckbox(partner, ev) {
|
||||
if (!ev.target.checked) {
|
||||
this.update({ selectedPartners: unlink(partner) });
|
||||
return;
|
||||
}
|
||||
this.update({ selectedPartners: link(partner) });
|
||||
},
|
||||
/**
|
||||
* @param {InputEvent} ev
|
||||
*/
|
||||
async onInputSearch(ev) {
|
||||
this.update({ searchTerm: ev.target.value });
|
||||
this.searchPartnersToInvite();
|
||||
},
|
||||
/**
|
||||
* Searches for partners to invite based on the current search term. If
|
||||
* a search is already in progress, waits until it is done to start a
|
||||
* new one.
|
||||
*/
|
||||
async searchPartnersToInvite() {
|
||||
if (this.hasSearchRpcInProgress) {
|
||||
this.update({ hasPendingSearchRpc: true });
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
hasPendingSearchRpc: false,
|
||||
hasSearchRpcInProgress: true,
|
||||
});
|
||||
try {
|
||||
const channelId = (this.thread && this.thread.model === 'mail.channel') ? this.thread.id : undefined;
|
||||
const { count, partners: partnersData } = await this.messaging.rpc(
|
||||
{
|
||||
model: 'res.partner',
|
||||
method: 'search_for_channel_invite',
|
||||
kwargs: {
|
||||
channel_id: channelId,
|
||||
search_term: cleanSearchTerm(this.searchTerm),
|
||||
},
|
||||
},
|
||||
{ shadow: true }
|
||||
);
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
searchResultCount: count,
|
||||
selectablePartners: partnersData,
|
||||
});
|
||||
} finally {
|
||||
if (this.exists()) {
|
||||
this.update({ hasSearchRpcInProgress: false });
|
||||
if (this.hasPendingSearchRpc) {
|
||||
this.searchPartnersToInvite();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
accessRestrictedToGroupText: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.thread.authorizedGroupFullName) {
|
||||
return clear();
|
||||
}
|
||||
return sprintf(
|
||||
this.env._t('Access restricted to group "%(groupFullName)s"'),
|
||||
{ 'groupFullName': this.thread.authorizedGroupFullName }
|
||||
);
|
||||
},
|
||||
}),
|
||||
chatWindow: one('ChatWindow', {
|
||||
identifying: true,
|
||||
inverse: 'channelInvitationForm',
|
||||
}),
|
||||
/**
|
||||
* States the OWL component of this channel invitation form.
|
||||
* Useful to be able to close it with popover trigger, or to know when
|
||||
* it is open to update the button active state.
|
||||
*/
|
||||
component: attr(),
|
||||
/**
|
||||
* Determines whether this search input needs to be focused.
|
||||
*/
|
||||
doFocusOnSearchInput: attr(),
|
||||
/**
|
||||
* States whether there is a pending search RPC.
|
||||
*/
|
||||
hasPendingSearchRpc: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States whether there is search RPC in progress.
|
||||
*/
|
||||
hasSearchRpcInProgress: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines the text of the invite button.
|
||||
*/
|
||||
inviteButtonText: attr({
|
||||
compute() {
|
||||
if (!this.thread || !this.thread.channel) {
|
||||
return clear();
|
||||
}
|
||||
switch (this.thread.channel.channel_type) {
|
||||
case 'chat':
|
||||
return this.env._t("Create group chat");
|
||||
case 'group':
|
||||
return this.env._t("Invite to group chat");
|
||||
}
|
||||
return this.env._t("Invite to Channel");
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* If set, this channel invitation form is content of related popover view.
|
||||
*/
|
||||
popoverViewOwner: one('PopoverView', {
|
||||
identifying: true,
|
||||
inverse: 'channelInvitationForm',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* States the OWL ref of the "search" input of this channel invitation
|
||||
* form. Useful to be able to focus it.
|
||||
*/
|
||||
searchInputRef: attr(),
|
||||
/**
|
||||
* States the number of results of the last search.
|
||||
*/
|
||||
searchResultCount: attr({
|
||||
default: 0,
|
||||
}),
|
||||
/**
|
||||
* Determines the search term used to filter this list.
|
||||
*/
|
||||
searchTerm: attr({
|
||||
default: "",
|
||||
}),
|
||||
/**
|
||||
* States all partners that are potential choices according to this
|
||||
* search term.
|
||||
*/
|
||||
selectablePartners: many('Partner'),
|
||||
selectablePartnerViews: many('ChannelInvitationFormSelectablePartnerView', {
|
||||
compute() {
|
||||
if (this.selectablePartners.length === 0) {
|
||||
return clear();
|
||||
}
|
||||
return this.selectablePartners.map(partner => ({ partner }));
|
||||
},
|
||||
inverse: 'channelInvitationFormOwner',
|
||||
}),
|
||||
/**
|
||||
* Determines all partners that are currently selected.
|
||||
*/
|
||||
selectedPartners: many('Partner'),
|
||||
selectedPartnerViews: many('ChannelInvitationFormSelectedPartnerView', {
|
||||
compute() {
|
||||
if (this.selectedPartners.length === 0) {
|
||||
return clear();
|
||||
}
|
||||
return this.selectedPartners.map(partner => ({ partner }));
|
||||
},
|
||||
inverse: 'channelInvitationFormOwner',
|
||||
}),
|
||||
/**
|
||||
* States the thread on which this list operates (if any).
|
||||
*/
|
||||
thread: one('Thread', {
|
||||
compute() {
|
||||
if (
|
||||
this.popoverViewOwner &&
|
||||
this.popoverViewOwner.threadViewTopbarOwnerAsInvite &&
|
||||
this.popoverViewOwner.threadViewTopbarOwnerAsInvite.thread
|
||||
) {
|
||||
return this.popoverViewOwner.threadViewTopbarOwnerAsInvite.thread;
|
||||
}
|
||||
if (this.chatWindow && this.chatWindow.thread) {
|
||||
return this.chatWindow.thread;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelInvitationFormSelectablePartnerView',
|
||||
fields: {
|
||||
channelInvitationFormOwner: one('ChannelInvitationForm', {
|
||||
identifying: true,
|
||||
inverse: 'selectablePartnerViews',
|
||||
}),
|
||||
partner: one('Partner', {
|
||||
identifying: true,
|
||||
inverse: 'channelInvitationFormSelectablePartnerViews',
|
||||
}),
|
||||
personaImStatusIconView: one('PersonaImStatusIconView', {
|
||||
compute() {
|
||||
return this.partner.isImStatusSet ? {} : clear();
|
||||
},
|
||||
inverse: 'channelInvitationFormSelectablePartnerViewOwner',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelInvitationFormSelectedPartnerView',
|
||||
fields: {
|
||||
channelInvitationFormOwner: one('ChannelInvitationForm', {
|
||||
identifying: true,
|
||||
inverse: 'selectedPartnerViews',
|
||||
}),
|
||||
partner: one('Partner', {
|
||||
identifying: true,
|
||||
inverse: 'channelInvitationFormSelectedPartnerViews',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one, many } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelMember',
|
||||
fields: {
|
||||
avatarUrl: attr({
|
||||
compute() {
|
||||
if (this.persona.partner) {
|
||||
return `/mail/channel/${this.channel.id}/partner/${this.persona.partner.id}/avatar_128`;
|
||||
}
|
||||
if (this.persona.guest) {
|
||||
return `/mail/channel/${this.channel.id}/guest/${this.persona.guest.id}/avatar_128?unique=${this.persona.guest.name}`;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
callParticipantCards: many('CallParticipantCard', {
|
||||
inverse: 'channelMember',
|
||||
isCausal: true,
|
||||
}),
|
||||
channel: one('Channel', {
|
||||
inverse: 'channelMembers',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
channelAsMemberOfCurrentUser: one('Channel', {
|
||||
compute() {
|
||||
return this.isMemberOfCurrentUser ? this.channel : clear();
|
||||
},
|
||||
inverse: 'memberOfCurrentUser',
|
||||
}),
|
||||
channelAsOfflineMember: one('Channel', {
|
||||
compute() {
|
||||
if (this.persona.partner) {
|
||||
return !this.persona.partner.isOnline ? this.channel : clear();
|
||||
}
|
||||
if (this.persona.guest) {
|
||||
return !this.persona.guest.isOnline ? this.channel : clear();
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'orderedOfflineMembers',
|
||||
}),
|
||||
channelAsOnlineMember: one('Channel', {
|
||||
compute() {
|
||||
if (this.persona.partner) {
|
||||
return this.persona.partner.isOnline ? this.channel : clear();
|
||||
}
|
||||
if (this.persona.guest) {
|
||||
return this.persona.guest.isOnline ? this.channel : clear();
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'orderedOnlineMembers',
|
||||
}),
|
||||
channelMemberViews: many('ChannelMemberView', {
|
||||
inverse: 'channelMember',
|
||||
}),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
isMemberOfCurrentUser: attr({
|
||||
compute() {
|
||||
if (this.messaging.currentPartner) {
|
||||
return this.messaging.currentPartner.persona === this.persona;
|
||||
}
|
||||
if (this.messaging.currentGuest) {
|
||||
return this.messaging.currentGuest.persona === this.persona;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
isStreaming: attr({
|
||||
compute() {
|
||||
return Boolean(this.rtcSession && this.rtcSession.videoStream);
|
||||
},
|
||||
}),
|
||||
isTyping: attr({
|
||||
default: false,
|
||||
}),
|
||||
otherMemberLongTypingInThreadTimers: many('OtherMemberLongTypingInThreadTimer', {
|
||||
inverse: 'member',
|
||||
}),
|
||||
persona: one('Persona', {
|
||||
inverse: 'channelMembers',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
rtcSession: one('RtcSession', {
|
||||
inverse: 'channelMember',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelMemberListCategoryView',
|
||||
identifyingMode: 'xor',
|
||||
fields: {
|
||||
channel: one('Channel', {
|
||||
compute() {
|
||||
if (this.channelMemberListViewOwnerAsOffline) {
|
||||
return this.channelMemberListViewOwnerAsOffline.channel;
|
||||
}
|
||||
if (this.channelMemberListViewOwnerAsOnline) {
|
||||
return this.channelMemberListViewOwnerAsOnline.channel;
|
||||
}
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
channelMemberListViewOwnerAsOffline: one('ChannelMemberListView', {
|
||||
identifying: true,
|
||||
inverse: 'offlineCategoryView',
|
||||
}),
|
||||
channelMemberListViewOwnerAsOnline: one('ChannelMemberListView', {
|
||||
identifying: true,
|
||||
inverse: 'onlineCategoryView',
|
||||
}),
|
||||
channelMemberViews: many('ChannelMemberView', {
|
||||
compute() {
|
||||
if (this.members.length === 0) {
|
||||
return clear();
|
||||
}
|
||||
return this.members.map(channelMember => ({ channelMember }));
|
||||
},
|
||||
inverse: 'channelMemberListCategoryViewOwner',
|
||||
}),
|
||||
members: many('ChannelMember', {
|
||||
compute() {
|
||||
if (this.channelMemberListViewOwnerAsOnline) {
|
||||
return this.channel.orderedOnlineMembers;
|
||||
}
|
||||
if (this.channelMemberListViewOwnerAsOffline) {
|
||||
return this.channel.orderedOfflineMembers;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
title: attr({
|
||||
compute() {
|
||||
let categoryText = "";
|
||||
if (this.channelMemberListViewOwnerAsOnline) {
|
||||
categoryText = this.env._t("Online");
|
||||
}
|
||||
if (this.channelMemberListViewOwnerAsOffline) {
|
||||
categoryText = this.env._t("Offline");
|
||||
}
|
||||
return sprintf(
|
||||
this.env._t("%(categoryText)s - %(memberCount)s"),
|
||||
{
|
||||
categoryText,
|
||||
memberCount: this.members.length,
|
||||
}
|
||||
);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelMemberListView',
|
||||
identifyingMode: 'xor',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.channel.fetchChannelMembers();
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* Handles click on the "load more members" button.
|
||||
*/
|
||||
async onClickLoadMoreMembers() {
|
||||
this.channel.fetchChannelMembers();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
channel: one('Channel', {
|
||||
compute() {
|
||||
if (this.chatWindowOwner) {
|
||||
return this.chatWindowOwner.thread.channel;
|
||||
}
|
||||
if (this.threadViewOwner) {
|
||||
return this.threadViewOwner.thread.channel;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
chatWindowOwner: one('ChatWindow', {
|
||||
identifying: true,
|
||||
inverse: 'channelMemberListView',
|
||||
}),
|
||||
offlineCategoryView: one('ChannelMemberListCategoryView', {
|
||||
compute() {
|
||||
if (this.channel && this.channel.orderedOfflineMembers.length > 0) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'channelMemberListViewOwnerAsOffline',
|
||||
}),
|
||||
onlineCategoryView: one('ChannelMemberListCategoryView', {
|
||||
compute() {
|
||||
if (this.channel && this.channel.orderedOnlineMembers.length > 0) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'channelMemberListViewOwnerAsOnline',
|
||||
}),
|
||||
threadViewOwner: one('ThreadView', {
|
||||
identifying: true,
|
||||
inverse: 'channelMemberListView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { isEventHandled } from '@mail/utils/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelMemberView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Handles click on channel member in the member list of this channel.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickMember(ev) {
|
||||
if (isEventHandled(ev, 'PersonaImStatusIcon.Click') || !this.channelMember.persona.partner) {
|
||||
return;
|
||||
}
|
||||
this.channelMember.persona.partner.openChat();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
channelMemberListCategoryViewOwner: one('ChannelMemberListCategoryView', {
|
||||
identifying: true,
|
||||
inverse: 'channelMemberViews',
|
||||
}),
|
||||
channelMember: one('ChannelMember', {
|
||||
identifying: true,
|
||||
inverse: 'channelMemberViews',
|
||||
}),
|
||||
hasOpenChat: attr({
|
||||
compute() {
|
||||
return this.channelMember.persona.partner ? true : false;
|
||||
},
|
||||
}),
|
||||
memberTitleText: attr({
|
||||
compute() {
|
||||
return this.hasOpenChat ? this.env._t("Open chat") : '';
|
||||
},
|
||||
}),
|
||||
personaImStatusIconView: one('PersonaImStatusIconView', {
|
||||
compute() {
|
||||
if (this.channelMember.persona.guest && this.channelMember.persona.guest.im_status) {
|
||||
return {};
|
||||
}
|
||||
return this.channelMember.persona.partner && this.channelMember.persona.partner.isImStatusSet ? {} : clear();
|
||||
},
|
||||
inverse: 'channelMemberViewOwner',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { htmlToTextContentInline } from '@mail/js/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'ChannelPreviewView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
const markAsRead = this.markAsReadRef.el;
|
||||
if (markAsRead && markAsRead.contains(ev.target)) {
|
||||
// handled in `_onClickMarkAsRead`
|
||||
return;
|
||||
}
|
||||
this.thread.open();
|
||||
if (!this.messaging.device.isSmall) {
|
||||
this.messaging.messagingMenu.close();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickMarkAsRead(ev) {
|
||||
if (this.thread.lastNonTransientMessage) {
|
||||
this.thread.markAsSeen(this.thread.lastNonTransientMessage);
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
channel: one('Channel', {
|
||||
identifying: true,
|
||||
inverse: 'channelPreviewViews',
|
||||
}),
|
||||
imageUrl: attr({
|
||||
compute() {
|
||||
if (this.channel.correspondent) {
|
||||
return this.channel.correspondent.avatarUrl;
|
||||
}
|
||||
return `/web/image/mail.channel/${this.channel.id}/avatar_128?unique=${this.channel.avatarCacheKey}`;
|
||||
},
|
||||
}),
|
||||
inlineLastMessageBody: attr({
|
||||
compute() {
|
||||
if (!this.thread || !this.thread.lastMessage) {
|
||||
return clear();
|
||||
}
|
||||
return htmlToTextContentInline(this.thread.lastMessage.prettyBody);
|
||||
},
|
||||
default: "",
|
||||
}),
|
||||
isEmpty: attr({
|
||||
compute() {
|
||||
return !this.inlineLastMessageBody && !this.lastTrackingValue;
|
||||
},
|
||||
}),
|
||||
lastTrackingValue: one('TrackingValue', {
|
||||
compute() {
|
||||
if (this.thread && this.thread.lastMessage && this.thread.lastMessage.lastTrackingValue) {
|
||||
return this.thread.lastMessage.lastTrackingValue;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Reference of the "mark as read" button. Useful to disable the
|
||||
* top-level click handler when clicking on this specific button.
|
||||
*/
|
||||
markAsReadRef: attr(),
|
||||
messageAuthorPrefixView: one('MessageAuthorPrefixView', {
|
||||
compute() {
|
||||
if (
|
||||
this.thread &&
|
||||
this.thread.lastMessage &&
|
||||
this.thread.lastMessage.author
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'channelPreviewViewOwner',
|
||||
}),
|
||||
notificationListViewOwner: one('NotificationListView', {
|
||||
identifying: true,
|
||||
inverse: 'channelPreviewViews',
|
||||
}),
|
||||
personaImStatusIconView: one('PersonaImStatusIconView', {
|
||||
compute() {
|
||||
if (!this.channel.correspondent) {
|
||||
return clear();
|
||||
}
|
||||
if (this.channel.correspondent.isImStatusSet) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'channelPreviewViewOwner',
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
related: 'channel.thread',
|
||||
}),
|
||||
},
|
||||
});
|
||||
655
odoo-bringout-oca-ocb-mail/mail/static/src/models/chat_window.js
Normal file
655
odoo-bringout-oca-ocb-mail/mail/static/src/models/chat_window.js
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { isEventHandled, markEventHandled } from '@mail/utils/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'ChatWindow',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Close this chat window.
|
||||
*
|
||||
* @param {Object} [param0={}]
|
||||
* @param {boolean} [param0.notifyServer]
|
||||
*/
|
||||
close({ notifyServer } = {}) {
|
||||
if (notifyServer === undefined) {
|
||||
notifyServer = !this.messaging.device.isSmall;
|
||||
}
|
||||
if (this.messaging.device.isSmall && !this.messaging.discuss.discussView) {
|
||||
// If we are in mobile and discuss is not open, it means the
|
||||
// chat window was opened from the messaging menu. In that
|
||||
// case it should be re-opened to simulate it was always
|
||||
// there in the background.
|
||||
this.messaging.messagingMenu.update({ isOpen: true });
|
||||
}
|
||||
// Flux specific: 'closed' fold state should only be saved on the
|
||||
// server when manually closing the chat window. Delete at destroy
|
||||
// or sync from server value for example should not save the value.
|
||||
if (this.thread && notifyServer && !this.messaging.currentGuest) {
|
||||
this.thread.notifyFoldStateToServer('closed');
|
||||
}
|
||||
if (this.exists()) {
|
||||
this.delete();
|
||||
}
|
||||
},
|
||||
expand() {
|
||||
if (this.thread) {
|
||||
this.thread.open({ expanded: true });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Programmatically auto-focus an existing chat window.
|
||||
*/
|
||||
focus() {
|
||||
if (!this.thread) {
|
||||
this.update({ isDoFocus: true });
|
||||
}
|
||||
if (this.threadView && this.threadView.composerView) {
|
||||
this.threadView.composerView.update({ doFocus: true });
|
||||
}
|
||||
},
|
||||
focusNextVisibleUnfoldedChatWindow() {
|
||||
const nextVisibleUnfoldedChatWindow = this._getNextVisibleUnfoldedChatWindow();
|
||||
if (nextVisibleUnfoldedChatWindow) {
|
||||
nextVisibleUnfoldedChatWindow.focus();
|
||||
}
|
||||
},
|
||||
focusPreviousVisibleUnfoldedChatWindow() {
|
||||
const previousVisibleUnfoldedChatWindow =
|
||||
this._getNextVisibleUnfoldedChatWindow({ reverse: true });
|
||||
if (previousVisibleUnfoldedChatWindow) {
|
||||
previousVisibleUnfoldedChatWindow.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} [param0={}]
|
||||
* @param {boolean} [param0.notifyServer]
|
||||
*/
|
||||
fold({ notifyServer } = {}) {
|
||||
if (notifyServer === undefined) {
|
||||
notifyServer = !this.messaging.device.isSmall;
|
||||
}
|
||||
this.update({ isFolded: true });
|
||||
// Flux specific: manually folding the chat window should save the
|
||||
// new state on the server.
|
||||
if (this.thread && notifyServer && !this.messaging.currentGuest) {
|
||||
this.thread.notifyFoldStateToServer('folded');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Makes this chat window active, which consists of making it visible,
|
||||
* unfolding it, and focusing it if the user isn't on a mobile device.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
*/
|
||||
makeActive(options) {
|
||||
this.makeVisible();
|
||||
this.unfold(options);
|
||||
if ((options && options.focus !== undefined) ? options.focus : !this.messaging.device.isMobileDevice) {
|
||||
this.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Makes this chat window visible by swapping it with the last visible
|
||||
* chat window, or do nothing if it is already visible.
|
||||
*/
|
||||
makeVisible() {
|
||||
if (this.isVisible) {
|
||||
return;
|
||||
}
|
||||
const lastVisible = this.manager.lastVisible;
|
||||
this.manager.swap(this, lastVisible);
|
||||
},
|
||||
/**
|
||||
* Called when selecting an item in the autocomplete input of the
|
||||
* 'new_message' chat window.
|
||||
*
|
||||
* @param {Event} ev
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
async onAutocompleteSelect(ev, ui) {
|
||||
const chat = await this.messaging.getChat({ partnerId: ui.item.id });
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
this.messaging.chatWindowManager.openThread(chat.thread, {
|
||||
makeActive: true,
|
||||
replaceNewMessage: true,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Called when typing in the autocomplete input of the 'new_message' chat
|
||||
* window.
|
||||
*
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
onAutocompleteSource(req, res) {
|
||||
this.messaging.models['Partner'].imSearch({
|
||||
callback: (partners) => {
|
||||
const suggestions = partners.map(partner => {
|
||||
return {
|
||||
id: partner.id,
|
||||
value: partner.nameOrDisplayName,
|
||||
label: partner.nameOrDisplayName,
|
||||
};
|
||||
});
|
||||
res(_.sortBy(suggestions, 'label'));
|
||||
},
|
||||
keyword: _.escape(req.term),
|
||||
limit: 10,
|
||||
});
|
||||
},
|
||||
onClickFromChatWindowHiddenMenu() {
|
||||
this.makeActive();
|
||||
this.manager.closeHiddenMenu();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickCamera(ev) {
|
||||
ev.stopPropagation();
|
||||
if (this.thread.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
await this.thread.toggleCall({ startWithVideo: true });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickClose(ev) {
|
||||
ev.stopPropagation();
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickExpand(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.expand();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on header of chat window. Usually folds the chat
|
||||
* window.
|
||||
*/
|
||||
onClickHeader(ev) {
|
||||
if (!this.exists() || this.messaging.device.isSmall) {
|
||||
return;
|
||||
}
|
||||
if (this.isFolded) {
|
||||
this.unfold();
|
||||
this.focus();
|
||||
} else {
|
||||
this.saveThreadScrollTop();
|
||||
this.fold();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickHideCallSettingsMenu(ev) {
|
||||
markEventHandled(ev, 'ChatWindow.onClickCommand');
|
||||
this.update({ isCallSettingsMenuOpen: false });
|
||||
},
|
||||
/**
|
||||
* Handles click on the "stop adding users" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickHideInviteForm(ev) {
|
||||
markEventHandled(ev, 'ChatWindow.onClickCommand');
|
||||
this.update({ channelInvitationForm: clear() });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickHideMemberList(ev) {
|
||||
markEventHandled(ev, 'ChatWindow.onClickHideMemberList');
|
||||
this.update({ isMemberListOpened: false });
|
||||
if (this.threadViewer.threadView) {
|
||||
this.threadViewer.threadView.addComponentHint('member-list-hidden');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickPhone(ev) {
|
||||
ev.stopPropagation();
|
||||
if (this.thread.hasPendingRtcRequest) {
|
||||
return;
|
||||
}
|
||||
await this.thread.toggleCall();
|
||||
},
|
||||
/**
|
||||
* Handles click on the "add users" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickShowInviteForm(ev) {
|
||||
markEventHandled(ev, 'ChatWindow.onClickCommand');
|
||||
this.update({
|
||||
channelInvitationForm: {
|
||||
doFocusOnSearchInput: true,
|
||||
},
|
||||
isMemberListOpened: false,
|
||||
});
|
||||
if (!this.messaging.isCurrentUserGuest) {
|
||||
this.channelInvitationForm.searchPartnersToInvite();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickShowCallSettingsMenu(ev) {
|
||||
markEventHandled(ev, 'ChatWindow.onClickCommand');
|
||||
this.update({
|
||||
isCallSettingsMenuOpen: true,
|
||||
isMemberListOpened: false,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickShowMemberList(ev) {
|
||||
markEventHandled(ev, 'ChatWindow.onClickShowMemberList');
|
||||
this.update({
|
||||
channelInvitationForm: clear(),
|
||||
isCallSettingsMenuOpen: false,
|
||||
isMemberListOpened: true,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onFocusInNewMessageFormInput(ev) {
|
||||
if (this.exists()) {
|
||||
this.update({ isFocused: true });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Focus out the chat window.
|
||||
*/
|
||||
onFocusout() {
|
||||
if (!this.exists()) {
|
||||
// ignore focus out due to record being deleted
|
||||
return;
|
||||
}
|
||||
this.update({ isFocused: false });
|
||||
},
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onKeydown(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
switch (ev.key) {
|
||||
case 'Tab':
|
||||
ev.preventDefault();
|
||||
if (ev.shiftKey) {
|
||||
this.focusPreviousVisibleUnfoldedChatWindow();
|
||||
} else {
|
||||
this.focusNextVisibleUnfoldedChatWindow();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) {
|
||||
break;
|
||||
}
|
||||
if (isEventHandled(ev, 'Composer.closeEmojisPopover')) {
|
||||
break;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this.focusNextVisibleUnfoldedChatWindow();
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Save the scroll positions of the chat window in the store.
|
||||
* This is useful in order to remount chat windows and keep previous
|
||||
* scroll positions. This is necessary because when toggling on/off
|
||||
* home menu, the chat windows have to be remade from scratch.
|
||||
*/
|
||||
saveThreadScrollTop() {
|
||||
if (
|
||||
!this.threadView ||
|
||||
!this.threadView.messageListView ||
|
||||
!this.threadView.messageListView.component ||
|
||||
!this.threadViewer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.threadViewer.threadView &&
|
||||
this.threadViewer.threadView.componentHintList.length > 0
|
||||
) {
|
||||
// the current scroll position is likely incorrect due to the
|
||||
// presence of hints to adjust it
|
||||
return;
|
||||
}
|
||||
this.threadViewer.saveThreadCacheScrollHeightAsInitial(
|
||||
this.threadView.messageListView.getScrollableElement().scrollHeight
|
||||
);
|
||||
this.threadViewer.saveThreadCacheScrollPositionsAsInitial(
|
||||
this.threadView.messageListView.getScrollableElement().scrollTop
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @param {Object} [param0={}]
|
||||
* @param {boolean} [param0.notifyServer]
|
||||
*/
|
||||
unfold({ notifyServer } = {}) {
|
||||
if (notifyServer === undefined) {
|
||||
notifyServer = !this.messaging.device.isSmall;
|
||||
}
|
||||
this.update({ isFolded: false });
|
||||
// Flux specific: manually opening the chat window should save the
|
||||
// new state on the server.
|
||||
if (this.thread && notifyServer && !this.messaging.currentGuest) {
|
||||
this.thread.notifyFoldStateToServer('open');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Cycles to the next possible visible and unfolded chat window starting
|
||||
* from the `currentChatWindow`, following the natural order based on the
|
||||
* current text direction, and with the possibility to `reverse` based on
|
||||
* the given parameter.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} [param0={}]
|
||||
* @param {boolean} [param0.reverse=false]
|
||||
* @returns {ChatWindow|undefined}
|
||||
*/
|
||||
_getNextVisibleUnfoldedChatWindow({ reverse = false } = {}) {
|
||||
const orderedVisible = this.manager.allOrderedVisible;
|
||||
/**
|
||||
* Return index of next visible chat window of a given visible chat
|
||||
* window index. The direction of "next" chat window depends on
|
||||
* `reverse` option.
|
||||
*
|
||||
* @param {integer} index
|
||||
* @returns {integer}
|
||||
*/
|
||||
const _getNextIndex = index => {
|
||||
const directionOffset = reverse ? 1 : -1;
|
||||
let nextIndex = index + directionOffset;
|
||||
if (nextIndex > orderedVisible.length - 1) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = orderedVisible.length - 1;
|
||||
}
|
||||
return nextIndex;
|
||||
};
|
||||
|
||||
const currentIndex = orderedVisible.findIndex(visible => visible === this);
|
||||
let nextIndex = _getNextIndex(currentIndex);
|
||||
let nextToFocus = orderedVisible[nextIndex];
|
||||
while (nextToFocus.isFolded) {
|
||||
nextIndex = _getNextIndex(nextIndex);
|
||||
nextToFocus = orderedVisible[nextIndex];
|
||||
}
|
||||
return nextToFocus;
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Model for the component with the controls for RTC related settings.
|
||||
*/
|
||||
callSettingsMenu: one('CallSettingsMenu', {
|
||||
compute() {
|
||||
if (this.isCallSettingsMenuOpen) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'chatWindowOwner',
|
||||
}),
|
||||
/**
|
||||
* Determines the channel invitation form displayed by this chat window
|
||||
* (if any). Only makes sense if hasInviteFeature is true.
|
||||
*/
|
||||
channelInvitationForm: one('ChannelInvitationForm', {
|
||||
inverse: 'chatWindow',
|
||||
}),
|
||||
channelMemberListView: one('ChannelMemberListView', {
|
||||
compute() {
|
||||
if (this.thread && this.thread.hasMemberListFeature && this.isMemberListOpened) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'chatWindowOwner',
|
||||
}),
|
||||
chatWindowHeaderView: one('ChatWindowHeaderView', {
|
||||
default: {},
|
||||
inverse: 'chatWindowOwner',
|
||||
}),
|
||||
componentStyle: attr({
|
||||
compute() {
|
||||
const textDirection = this.messaging.locale.textDirection;
|
||||
const offsetFrom = textDirection === 'rtl' ? 'left' : 'right';
|
||||
const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right';
|
||||
return `${offsetFrom}: ${this.visibleOffset}px; ${oppositeFrom}: auto`;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines whether the buttons to start a RTC call should be displayed.
|
||||
*/
|
||||
hasCallButtons: attr({
|
||||
compute() {
|
||||
if (!this.thread || !this.thread.channel) {
|
||||
return clear();
|
||||
}
|
||||
return this.thread.rtcSessions.length === 0 && ['channel', 'chat', 'group'].includes(this.thread.channel.channel_type);
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
hasCloseAsBackButton: attr({
|
||||
compute() {
|
||||
if (this.isVisible && this.messaging.device.isSmall) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States whether this chat window has the invite feature.
|
||||
*/
|
||||
hasInviteFeature: attr({
|
||||
compute() {
|
||||
return Boolean(
|
||||
this.thread && this.thread.hasInviteFeature &&
|
||||
this.messaging && this.messaging.device && this.messaging.device.isSmall
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines whether "new message form" should be displayed.
|
||||
*/
|
||||
hasNewMessageForm: attr({
|
||||
compute() {
|
||||
return this.isVisible && !this.isFolded && !this.thread;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines whether `this.thread` should be displayed.
|
||||
*/
|
||||
hasThreadView: attr({
|
||||
compute() {
|
||||
return this.isVisible && !this.isFolded && !!this.thread && !this.isMemberListOpened && !this.channelInvitationForm && !this.isCallSettingsMenuOpen;
|
||||
},
|
||||
}),
|
||||
isCallSettingsMenuOpen: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determine whether the chat window should be programmatically
|
||||
* focused by observed component of chat window. Those components
|
||||
* are responsible to unmark this record afterwards, otherwise
|
||||
* any re-render will programmatically set focus again!
|
||||
*/
|
||||
isDoFocus: attr({
|
||||
default: false,
|
||||
}),
|
||||
isExpandable: attr({
|
||||
compute() {
|
||||
if (this.isVisible && !this.messaging.device.isSmall && this.thread) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States whether `this` is focused. Useful for visual clue.
|
||||
*/
|
||||
isFocused: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines whether `this` is folded.
|
||||
*/
|
||||
isFolded: attr({
|
||||
default: false,
|
||||
}),
|
||||
isFullscreen: attr({
|
||||
compute() {
|
||||
if (this.isVisible && this.messaging.device.isSmall) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines whether the member list of this chat window is opened.
|
||||
* Only makes sense if this thread hasMemberListFeature is true.
|
||||
*/
|
||||
isMemberListOpened: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States whether `this` is visible or not. Should be considered
|
||||
* read-only. Setting this value manually will not make it visible.
|
||||
* @see `makeVisible`
|
||||
*/
|
||||
isVisible: attr({
|
||||
compute() {
|
||||
if (!this.manager) {
|
||||
return false;
|
||||
}
|
||||
return this.manager.allOrderedVisible.includes(this);
|
||||
},
|
||||
}),
|
||||
manager: one('ChatWindowManager', {
|
||||
inverse: 'chatWindows',
|
||||
readonly: true,
|
||||
}),
|
||||
managerAsNewMessage: one('ChatWindowManager', {
|
||||
identifying: true,
|
||||
inverse: 'newMessageChatWindow',
|
||||
}),
|
||||
name: attr({
|
||||
compute() {
|
||||
if (this.thread) {
|
||||
return this.thread.displayName;
|
||||
}
|
||||
return this.env._t("New message");
|
||||
},
|
||||
}),
|
||||
newMessageAutocompleteInputView: one('AutocompleteInputView', {
|
||||
compute() {
|
||||
if (this.hasNewMessageForm) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'chatWindowOwnerAsNewMessage',
|
||||
}),
|
||||
/**
|
||||
* The content of placeholder for the autocomplete input of
|
||||
* 'new_message' chat window.
|
||||
*/
|
||||
newMessageFormInputPlaceholder: attr({
|
||||
compute() {
|
||||
return this.env._t("Search user...");
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines the `Thread` that should be displayed by `this`.
|
||||
* If no `Thread` is linked, `this` is considered "new message".
|
||||
*/
|
||||
thread: one('Thread', {
|
||||
identifying: true,
|
||||
inverse: 'chatWindow',
|
||||
}),
|
||||
/**
|
||||
* States the `ThreadView` displaying `this.thread`.
|
||||
*/
|
||||
threadView: one('ThreadView', {
|
||||
related: 'threadViewer.threadView',
|
||||
}),
|
||||
/**
|
||||
* Determines the `ThreadViewer` managing the display of `this.thread`.
|
||||
*/
|
||||
threadViewer: one('ThreadViewer', {
|
||||
compute() {
|
||||
return {
|
||||
compact: true,
|
||||
hasThreadView: this.hasThreadView,
|
||||
thread: this.thread ? this.thread : clear(),
|
||||
};
|
||||
},
|
||||
inverse: 'chatWindow',
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* This field handle the "order" (index) of the visible chatWindow inside the UI.
|
||||
*
|
||||
* Using LTR, the right-most chat window has index 0, and the number is incrementing from right to left.
|
||||
* Using RTL, the left-most chat window has index 0, and the number is incrementing from left to right.
|
||||
*/
|
||||
visibleIndex: attr({
|
||||
compute() {
|
||||
if (!this.manager) {
|
||||
return clear();
|
||||
}
|
||||
const visible = this.manager.visual.visible;
|
||||
const index = visible.findIndex(visible => visible.chatWindow === this);
|
||||
if (index === -1) {
|
||||
return clear();
|
||||
}
|
||||
return index;
|
||||
},
|
||||
}),
|
||||
visibleOffset: attr({
|
||||
compute() {
|
||||
if (!this.manager) {
|
||||
return 0;
|
||||
}
|
||||
const visible = this.manager.visual.visible;
|
||||
const index = visible.findIndex(visible => visible.chatWindow === this);
|
||||
if (index === -1) {
|
||||
return 0;
|
||||
}
|
||||
return visible[index].offset;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
import { isEventHandled } from '@mail/utils/utils';
|
||||
|
||||
registerModel({
|
||||
name: 'ChatWindowHeaderView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isEventHandled(ev, 'ChatWindow.onClickCommand') ||
|
||||
isEventHandled(ev, 'ChatWindow.onClickHideMemberList') ||
|
||||
isEventHandled(ev, 'ChatWindow.onClickShowMemberList')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!this.chatWindowOwner.isVisible) {
|
||||
this.chatWindowOwner.onClickFromChatWindowHiddenMenu(ev);
|
||||
} else {
|
||||
this.chatWindowOwner.onClickHeader(ev);
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
chatWindowOwner: one('ChatWindow', {
|
||||
identifying: true,
|
||||
inverse: 'chatWindowHeaderView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
export const BASE_VISUAL = {
|
||||
/**
|
||||
* @deprecated, should use ChatWindowManager/availableVisibleSlots instead
|
||||
* Amount of visible slots available for chat windows.
|
||||
*/
|
||||
availableVisibleSlots: 0,
|
||||
/**
|
||||
* List of hidden docked chat windows. Useful to compute counter.
|
||||
* Chat windows are ordered by their `chatWindows` order.
|
||||
*/
|
||||
hiddenChatWindows: [],
|
||||
/**
|
||||
* Whether hidden menu is visible or not
|
||||
*/
|
||||
isHiddenMenuVisible: false,
|
||||
/**
|
||||
* Offset of hidden menu starting point from the starting point
|
||||
* of chat window manager. Makes only sense if it is visible.
|
||||
*/
|
||||
hiddenMenuOffset: 0,
|
||||
/**
|
||||
* Data related to visible chat windows. Index determine order of
|
||||
* docked chat windows.
|
||||
*
|
||||
* Value:
|
||||
*
|
||||
* {
|
||||
* chatWindow,
|
||||
* offset,
|
||||
* }
|
||||
*
|
||||
* Offset is offset of starting point of docked chat window from
|
||||
* starting point of dock chat window manager. Docked chat windows
|
||||
* are ordered by their `chatWindows` order
|
||||
*/
|
||||
visible: [],
|
||||
};
|
||||
|
||||
registerModel({
|
||||
name: 'ChatWindowManager',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Close all chat windows.
|
||||
*
|
||||
*/
|
||||
closeAll() {
|
||||
for (const chatWindow of this.chatWindows) {
|
||||
chatWindow.close();
|
||||
}
|
||||
},
|
||||
closeHiddenMenu() {
|
||||
this.update({ isHiddenMenuOpen: false });
|
||||
},
|
||||
/**
|
||||
* Closes all chat windows related to the given thread.
|
||||
*
|
||||
* @param {Thread} thread
|
||||
* @param {Object} [options]
|
||||
*/
|
||||
closeThread(thread, options) {
|
||||
for (const chatWindow of this.chatWindows) {
|
||||
if (chatWindow.thread === thread) {
|
||||
chatWindow.close(options);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickHiddenMenuToggler(ev) {
|
||||
if (this.isHiddenMenuOpen) {
|
||||
this.closeHiddenMenu();
|
||||
} else {
|
||||
this.openHiddenMenu();
|
||||
}
|
||||
},
|
||||
openHiddenMenu() {
|
||||
this.update({ isHiddenMenuOpen: true });
|
||||
},
|
||||
openNewMessage() {
|
||||
if (!this.newMessageChatWindow) {
|
||||
this.update({ newMessageChatWindow: { manager: this } });
|
||||
}
|
||||
this.newMessageChatWindow.makeActive();
|
||||
},
|
||||
/**
|
||||
* @param {Thread} thread
|
||||
* @param {Object} [param1={}]
|
||||
* @param {boolean} [param1.focus] if set, set focus the chat window
|
||||
* to open.
|
||||
* @param {boolean} [param1.isFolded=false]
|
||||
* @param {boolean} [param1.makeActive=false]
|
||||
* @param {boolean} [param1.notifyServer]
|
||||
* @param {boolean} [param1.replaceNewMessage=false]
|
||||
*/
|
||||
openThread(thread, {
|
||||
focus,
|
||||
isFolded = false,
|
||||
makeActive = false,
|
||||
notifyServer,
|
||||
replaceNewMessage = false
|
||||
} = {}) {
|
||||
if (notifyServer === undefined) {
|
||||
notifyServer = !this.messaging.device.isSmall;
|
||||
}
|
||||
let chatWindow = thread.chatWindow;
|
||||
if (!chatWindow) {
|
||||
chatWindow = this.messaging.models['ChatWindow'].insert({
|
||||
isFolded,
|
||||
manager: this,
|
||||
thread,
|
||||
});
|
||||
} else {
|
||||
chatWindow.update({ isFolded });
|
||||
}
|
||||
if (replaceNewMessage && this.newMessageChatWindow) {
|
||||
this.swap(chatWindow, this.newMessageChatWindow);
|
||||
this.newMessageChatWindow.close();
|
||||
}
|
||||
if (makeActive) {
|
||||
// avoid double notify at this step, it will already be done at
|
||||
// the end of the current method
|
||||
chatWindow.makeActive({ focus, notifyServer: false });
|
||||
}
|
||||
// Flux specific: notify server of chat window being opened.
|
||||
if (notifyServer && !this.messaging.currentGuest) {
|
||||
const foldState = chatWindow.isFolded ? 'folded' : 'open';
|
||||
thread.notifyFoldStateToServer(foldState);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {ChatWindow} chatWindow1
|
||||
* @param {ChatWindow} chatWindow2
|
||||
*/
|
||||
swap(chatWindow1, chatWindow2) {
|
||||
const index1 = this.chatWindows.findIndex(chatWindow => chatWindow === chatWindow1);
|
||||
const index2 = this.chatWindows.findIndex(chatWindow => chatWindow === chatWindow2);
|
||||
if (index1 === -1 || index2 === -1) {
|
||||
return;
|
||||
}
|
||||
const _newOrdered = [...this.chatWindows];
|
||||
_newOrdered[index1] = chatWindow2;
|
||||
_newOrdered[index2] = chatWindow1;
|
||||
this.update({ chatWindows: _newOrdered });
|
||||
for (const chatWindow of [chatWindow1, chatWindow2]) {
|
||||
if (chatWindow.threadView) {
|
||||
chatWindow.threadView.addComponentHint('adjust-scroll');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
allOrderedHidden: many('ChatWindow', {
|
||||
compute() {
|
||||
return this.visual.hiddenChatWindows;
|
||||
},
|
||||
}),
|
||||
allOrderedVisible: many('ChatWindow', {
|
||||
compute() {
|
||||
return this.visual.visible.map(({ chatWindow }) => chatWindow);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Amount of visible slots available for chat windows.
|
||||
*/
|
||||
availableVisibleSlots: attr({
|
||||
compute() {
|
||||
return this.visual.availableVisibleSlots;
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
betweenGapWidth: attr({
|
||||
default: 5,
|
||||
}),
|
||||
chatWindows: many('ChatWindow', {
|
||||
inverse: 'manager',
|
||||
isCausal: true,
|
||||
}),
|
||||
chatWindowWidth: attr({
|
||||
default: 340,
|
||||
}),
|
||||
endGapWidth: attr({
|
||||
compute() {
|
||||
if (this.messaging.device.isSmall) {
|
||||
return 0;
|
||||
}
|
||||
return 10;
|
||||
},
|
||||
}),
|
||||
hasVisibleChatWindows: attr({
|
||||
compute() {
|
||||
return this.allOrderedVisible.length > 0;
|
||||
},
|
||||
}),
|
||||
hiddenChatWindowHeaderViews: many('ChatWindowHeaderView', {
|
||||
compute() {
|
||||
if (this.allOrderedHidden.length > 0) {
|
||||
return this.allOrderedHidden.map(chatWindow => ({ chatWindowOwner: chatWindow }));
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
hiddenMenuWidth: attr({
|
||||
default: 170, // max width, including width of dropup list items
|
||||
}),
|
||||
isHiddenMenuOpen: attr({
|
||||
default: false,
|
||||
}),
|
||||
lastVisible: one('ChatWindow', {
|
||||
compute() {
|
||||
const { length: l, [l - 1]: lastVisible } = this.allOrderedVisible;
|
||||
if (!lastVisible) {
|
||||
return clear();
|
||||
}
|
||||
return lastVisible;
|
||||
},
|
||||
}),
|
||||
newMessageChatWindow: one('ChatWindow', {
|
||||
inverse: 'managerAsNewMessage',
|
||||
}),
|
||||
startGapWidth: attr({
|
||||
compute() {
|
||||
if (this.messaging.device.isSmall) {
|
||||
return 0;
|
||||
}
|
||||
return 10;
|
||||
},
|
||||
}),
|
||||
unreadHiddenConversationAmount: attr({
|
||||
compute() {
|
||||
const allHiddenWithThread = this.allOrderedHidden.filter(
|
||||
chatWindow => chatWindow.thread
|
||||
);
|
||||
let amount = 0;
|
||||
for (const chatWindow of allHiddenWithThread) {
|
||||
if (chatWindow.thread.channel && chatWindow.thread.channel.localMessageUnreadCounter > 0) {
|
||||
amount++;
|
||||
}
|
||||
}
|
||||
return amount;
|
||||
},
|
||||
}),
|
||||
visual: attr({
|
||||
compute() {
|
||||
let visual = JSON.parse(JSON.stringify(BASE_VISUAL));
|
||||
if (!this.messaging || !this.messaging.device) {
|
||||
return visual;
|
||||
}
|
||||
if (
|
||||
(!this.messaging.device.isSmall && this.messaging.discuss.discussView) ||
|
||||
this.messaging.discussPublicView
|
||||
) {
|
||||
return visual;
|
||||
}
|
||||
if (!this.chatWindows.length) {
|
||||
return visual;
|
||||
}
|
||||
const relativeGlobalWindowWidth = this.messaging.device.globalWindowInnerWidth - this.startGapWidth - this.endGapWidth;
|
||||
let maxAmountWithoutHidden = Math.floor(
|
||||
relativeGlobalWindowWidth / (this.chatWindowWidth + this.betweenGapWidth));
|
||||
let maxAmountWithHidden = Math.floor(
|
||||
(relativeGlobalWindowWidth - this.hiddenMenuWidth - this.betweenGapWidth) /
|
||||
(this.chatWindowWidth + this.betweenGapWidth));
|
||||
if (this.messaging.device.isSmall) {
|
||||
maxAmountWithoutHidden = 1;
|
||||
maxAmountWithHidden = 1;
|
||||
}
|
||||
if (this.chatWindows.length <= maxAmountWithoutHidden) {
|
||||
// all visible
|
||||
for (let i = 0; i < this.chatWindows.length; i++) {
|
||||
const chatWindow = this.chatWindows[i];
|
||||
const offset = this.startGapWidth + i * (this.chatWindowWidth + this.betweenGapWidth);
|
||||
visual.visible.push({ chatWindow, offset });
|
||||
}
|
||||
visual.availableVisibleSlots = maxAmountWithoutHidden;
|
||||
} else if (maxAmountWithHidden > 0) {
|
||||
// some visible, some hidden
|
||||
for (let i = 0; i < maxAmountWithHidden; i++) {
|
||||
const chatWindow = this.chatWindows[i];
|
||||
const offset = this.startGapWidth + i * (this.chatWindowWidth + this.betweenGapWidth);
|
||||
visual.visible.push({ chatWindow, offset });
|
||||
}
|
||||
if (this.chatWindows.length > maxAmountWithHidden) {
|
||||
visual.isHiddenMenuVisible = !this.messaging.device.isSmall;
|
||||
visual.hiddenMenuOffset = visual.visible[maxAmountWithHidden - 1].offset
|
||||
+ this.chatWindowWidth + this.betweenGapWidth;
|
||||
}
|
||||
for (let j = maxAmountWithHidden; j < this.chatWindows.length; j++) {
|
||||
visual.hiddenChatWindows.push(this.chatWindows[j]);
|
||||
}
|
||||
visual.availableVisibleSlots = maxAmountWithHidden;
|
||||
} else {
|
||||
// all hidden
|
||||
visual.isHiddenMenuVisible = !this.messaging.device.isSmall;
|
||||
visual.hiddenMenuOffset = this.startGapWidth;
|
||||
visual.hiddenChatWindows.push(...this.chatWindows);
|
||||
console.warn('cannot display any visible chat windows (screen is too small)');
|
||||
visual.availableVisibleSlots = 0;
|
||||
}
|
||||
return visual;
|
||||
},
|
||||
default: BASE_VISUAL,
|
||||
}),
|
||||
},
|
||||
});
|
||||
551
odoo-bringout-oca-ocb-mail/mail/static/src/models/chatter.js
Normal file
551
odoo-bringout-oca-ocb-mail/mail/static/src/models/chatter.js
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear, insert, link } from '@mail/model/model_field_command';
|
||||
import { makeDeferred } from '@mail/utils/deferred';
|
||||
|
||||
const getThreadNextTemporaryId = (function () {
|
||||
let tmpId = 0;
|
||||
return () => {
|
||||
tmpId -= 1;
|
||||
return tmpId;
|
||||
};
|
||||
})();
|
||||
|
||||
const getMessageNextTemporaryId = (function () {
|
||||
let tmpId = 0;
|
||||
return () => {
|
||||
tmpId -= 1;
|
||||
return tmpId;
|
||||
};
|
||||
})();
|
||||
|
||||
registerModel({
|
||||
name: 'Chatter',
|
||||
recordMethods: {
|
||||
focus() {
|
||||
if (this.composerView) {
|
||||
this.composerView.update({ doFocus: true });
|
||||
}
|
||||
},
|
||||
async doSaveRecord() {
|
||||
const saved = await this.saveRecord();
|
||||
if (!saved) {
|
||||
return saved;
|
||||
}
|
||||
let composerData = null;
|
||||
if (this.composerView) {
|
||||
const {
|
||||
attachments,
|
||||
isLog,
|
||||
rawMentionedChannels,
|
||||
rawMentionedPartners,
|
||||
textInputContent,
|
||||
textInputCursorEnd,
|
||||
textInputCursorStart,
|
||||
textInputSelectionDirection,
|
||||
} = this.composerView.composer;
|
||||
composerData = {
|
||||
attachments,
|
||||
isLog,
|
||||
rawMentionedChannels,
|
||||
rawMentionedPartners,
|
||||
textInputContent,
|
||||
textInputCursorEnd,
|
||||
textInputCursorStart,
|
||||
textInputSelectionDirection,
|
||||
};
|
||||
}
|
||||
// Wait for next render from chatter_container,
|
||||
// So that it changes to composer of new thread
|
||||
this.update({
|
||||
createNewRecordComposerData: composerData,
|
||||
createNewRecordDeferred: composerData ? makeDeferred() : null,
|
||||
});
|
||||
await this.createNewRecordDeferred;
|
||||
// Give some time to chatter model being updated by save.
|
||||
await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve)));
|
||||
return saved;
|
||||
},
|
||||
onAttachmentsLoadingTimeout() {
|
||||
this.update({
|
||||
attachmentsLoaderTimer: clear(),
|
||||
isShowingAttachmentsLoading: true,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Handles click on the attach button.
|
||||
*/
|
||||
async onClickButtonAddAttachments() {
|
||||
if (this.isTemporary) {
|
||||
const saved = await this.doSaveRecord();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.fileUploader.openBrowserFileUploader();
|
||||
},
|
||||
/**
|
||||
* Handles click on the attachments button.
|
||||
*/
|
||||
async onClickButtonToggleAttachments() {
|
||||
if (this.isTemporary) {
|
||||
const saved = await this.doSaveRecord();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.update({ attachmentBoxView: this.attachmentBoxView ? clear() : {} });
|
||||
if (this.attachmentBoxView) {
|
||||
this.scrollPanelRef.el.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles click on top bar close button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickChatterTopbarClose(ev) {
|
||||
this.component.trigger('o-close-chatter');
|
||||
},
|
||||
/**
|
||||
* Handles click on "log note" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickLogNote() {
|
||||
if (this.composerView && this.composerView.composer.isLog) {
|
||||
this.update({ composerView: clear() });
|
||||
} else {
|
||||
this.showLogNote();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles click on "schedule activity" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickScheduleActivity(ev) {
|
||||
if (this.isTemporary) {
|
||||
const saved = await this.doSaveRecord();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.messaging.openActivityForm({ thread: this.thread });
|
||||
if (this.exists()) {
|
||||
this.reloadParentView();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles click on "send message" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickSendMessage(ev) {
|
||||
if (this.composerView && !this.composerView.composer.isLog) {
|
||||
this.update({ composerView: clear() });
|
||||
} else {
|
||||
this.showSendMessage();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles scroll on this scroll panel.
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onScrollScrollPanel(ev) {
|
||||
if (!this.threadView || !this.threadView.messageListView || !this.threadView.messageListView.component) {
|
||||
return;
|
||||
}
|
||||
this.threadView.messageListView.component.onScroll(ev);
|
||||
},
|
||||
openAttachmentBoxView() {
|
||||
this.update({ attachmentBoxView: {} });
|
||||
},
|
||||
/**
|
||||
* Open a dialog to add partners as followers.
|
||||
*/
|
||||
promptAddPartnerFollower() {
|
||||
const action = {
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'mail.wizard.invite',
|
||||
view_mode: 'form',
|
||||
views: [[false, 'form']],
|
||||
name: this.env._t("Invite Follower"),
|
||||
target: 'new',
|
||||
context: {
|
||||
default_res_model: this.thread.model,
|
||||
default_res_id: this.thread.id,
|
||||
},
|
||||
};
|
||||
this.env.services.action.doAction(
|
||||
action,
|
||||
{
|
||||
onClose: async () => {
|
||||
if (!this.exists() && !this.thread) {
|
||||
return;
|
||||
}
|
||||
await this.thread.fetchData(['followers']);
|
||||
if (this.exists() && this.hasParentReloadOnFollowersUpdate) {
|
||||
this.reloadParentView();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
async refresh() {
|
||||
const requestData = ['activities', 'followers', 'suggestedRecipients'];
|
||||
if (this.hasMessageList) {
|
||||
requestData.push('attachments', 'messages');
|
||||
}
|
||||
this.thread.fetchData(requestData);
|
||||
},
|
||||
/**
|
||||
* @param {Object} [param0={}]
|
||||
* @param {string[]} [fieldNames]
|
||||
*/
|
||||
async reloadParentView({ fieldNames } = {}) {
|
||||
if (this.saveRecord) {
|
||||
await this.saveRecord();
|
||||
}
|
||||
if (this.webRecord) {
|
||||
await this.webRecord.model.root.load({ resId: this.threadId }, { keepChanges: true });
|
||||
this.webRecord.model.notify();
|
||||
return;
|
||||
}
|
||||
if (this.component) {
|
||||
const options = { keepChanges: true };
|
||||
if (fieldNames) {
|
||||
options.fieldNames = fieldNames;
|
||||
}
|
||||
this.component.trigger('reload', options);
|
||||
}
|
||||
},
|
||||
showLogNote() {
|
||||
this.update({ composerView: {} });
|
||||
this.composerView.composer.update({ isLog: true });
|
||||
this.focus();
|
||||
},
|
||||
showSendMessage() {
|
||||
this.update({ composerView: {} });
|
||||
this.composerView.composer.update({ isLog: false });
|
||||
this.focus();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onThreadIdOrThreadModelChanged() {
|
||||
if (!this.threadModel) {
|
||||
return;
|
||||
}
|
||||
if (this.threadId) {
|
||||
if (this.thread && this.thread.isTemporary) {
|
||||
this.thread.delete();
|
||||
}
|
||||
this.update({
|
||||
attachmentBoxView: this.isAttachmentBoxVisibleInitially ? {} : clear(),
|
||||
thread: insert({
|
||||
// If the thread was considered to have the activity
|
||||
// mixin once, it will have it forever.
|
||||
hasActivities: this.hasActivities ? true : undefined,
|
||||
id: this.threadId,
|
||||
model: this.threadModel,
|
||||
}),
|
||||
});
|
||||
} else if (!this.thread || !this.thread.isTemporary) {
|
||||
const currentPartner = this.messaging.currentPartner;
|
||||
const message = this.messaging.models['Message'].insert({
|
||||
author: currentPartner,
|
||||
body: this.env._t("Creating a new record..."),
|
||||
id: getMessageNextTemporaryId(),
|
||||
isTemporary: true,
|
||||
});
|
||||
const nextId = getThreadNextTemporaryId();
|
||||
this.update({
|
||||
attachmentBoxView: clear(),
|
||||
thread: insert({
|
||||
areAttachmentsLoaded: true,
|
||||
id: nextId,
|
||||
isTemporary: true,
|
||||
model: this.threadModel,
|
||||
}),
|
||||
});
|
||||
this.thread.cache.update({ temporaryMessages: link(message) });
|
||||
}
|
||||
// continuation of saving new record: restore composer state
|
||||
if (this.createNewRecordComposerData) {
|
||||
this.update({
|
||||
composerView: {
|
||||
composer: {
|
||||
...this.createNewRecordComposerData,
|
||||
thread: this.thread,
|
||||
},
|
||||
},
|
||||
});
|
||||
this.createNewRecordDeferred.resolve();
|
||||
}
|
||||
this.update({
|
||||
createNewRecordComposerData: clear(),
|
||||
createNewRecordDeferred: clear(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onThreadIsLoadingAttachmentsChanged() {
|
||||
if (!this.thread || !this.thread.isLoadingAttachments) {
|
||||
this.update({
|
||||
attachmentsLoaderTimer: clear(),
|
||||
isShowingAttachmentsLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.isPreparingAttachmentsLoading || this.isShowingAttachmentsLoading) {
|
||||
return;
|
||||
}
|
||||
this._prepareAttachmentsLoading();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_prepareAttachmentsLoading() {
|
||||
this.update({ attachmentsLoaderTimer: {} });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activityBoxView: one('ActivityBoxView', {
|
||||
compute() {
|
||||
if (this.thread && this.thread.hasActivities && this.thread.activities.length > 0) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'chatter',
|
||||
}),
|
||||
attachmentBoxView: one('AttachmentBoxView', {
|
||||
inverse: 'chatter',
|
||||
}),
|
||||
attachmentsLoaderTimer: one('Timer', {
|
||||
inverse: 'chatterOwnerAsAttachmentsLoader',
|
||||
}),
|
||||
canPostMessage: attr({
|
||||
compute() {
|
||||
return Boolean(this.isTemporary || this.hasWriteAccess ||
|
||||
(this.hasReadAccess && this.thread && this.thread.canPostOnReadonly));
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* States the OWL Chatter component of this chatter.
|
||||
*/
|
||||
component: attr(),
|
||||
/**
|
||||
* Determines the composer view used to post in this chatter (if any).
|
||||
*/
|
||||
composerView: one('ComposerView', {
|
||||
inverse: 'chatter',
|
||||
}),
|
||||
context: attr({
|
||||
default: {},
|
||||
}),
|
||||
dropZoneView: one('DropZoneView', {
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
if (this.useDragVisibleDropZone.isVisible) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'chatterOwner',
|
||||
}),
|
||||
fileUploader: one('FileUploader', {
|
||||
compute() {
|
||||
return this.thread ? {} : clear();
|
||||
},
|
||||
inverse: 'chatterOwner',
|
||||
}),
|
||||
followButtonView: one('FollowButtonView', {
|
||||
compute() {
|
||||
if (this.hasFollowers && this.thread && (!this.thread.channel || this.thread.channel.channel_type !== 'chat')) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'chatterOwner',
|
||||
}),
|
||||
followerListMenuView: one('FollowerListMenuView', {
|
||||
compute() {
|
||||
if (this.hasFollowers && this.thread) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'chatterOwner',
|
||||
}),
|
||||
/**
|
||||
* Determines whether `this` should display an activity box.
|
||||
*/
|
||||
hasActivities: attr({
|
||||
default: true,
|
||||
}),
|
||||
hasExternalBorder: attr({
|
||||
default: true,
|
||||
}),
|
||||
/**
|
||||
* Determines whether `this` should display followers menu.
|
||||
*/
|
||||
hasFollowers: attr({
|
||||
default: true,
|
||||
}),
|
||||
/**
|
||||
* Determines whether `this` should display a message list.
|
||||
*/
|
||||
hasMessageList: attr({
|
||||
default: true,
|
||||
}),
|
||||
/**
|
||||
* Whether the message list should manage its scroll.
|
||||
* In particular, when the chatter is on the form view's side,
|
||||
* then the scroll is managed by the message list.
|
||||
* Also, the message list shoud not manage the scroll if it shares it
|
||||
* with the rest of the page.
|
||||
*/
|
||||
hasMessageListScrollAdjust: attr({
|
||||
default: false,
|
||||
}),
|
||||
hasParentReloadOnAttachmentsChanged: attr({
|
||||
default: false,
|
||||
}),
|
||||
hasParentReloadOnFollowersUpdate: attr({
|
||||
default: false,
|
||||
}),
|
||||
hasParentReloadOnMessagePosted: attr({
|
||||
default: false,
|
||||
}),
|
||||
hasReadAccess: attr({
|
||||
compute() {
|
||||
return Boolean(this.thread && !this.thread.isTemporary && this.thread.hasReadAccess);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines whether `this.thread` should be displayed.
|
||||
*/
|
||||
hasThreadView: attr({
|
||||
compute() {
|
||||
return Boolean(this.thread && this.hasMessageList);
|
||||
},
|
||||
}),
|
||||
hasWriteAccess: attr({
|
||||
compute() {
|
||||
return Boolean(this.thread && !this.thread.isTemporary && this.thread.hasWriteAccess);
|
||||
},
|
||||
}),
|
||||
hasTopbarCloseButton: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States the id of this chatter. This id does not correspond to any
|
||||
* specific value, it is just a unique identifier given by the creator
|
||||
* of this record.
|
||||
*/
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
/**
|
||||
* Determiners whether the attachment box is visible initially.
|
||||
*/
|
||||
isAttachmentBoxVisibleInitially: attr({
|
||||
default: false,
|
||||
}),
|
||||
isInFormSheetBg: attr({
|
||||
default: false,
|
||||
}),
|
||||
isPreparingAttachmentsLoading: attr({
|
||||
compute() {
|
||||
return Boolean(this.attachmentsLoaderTimer);
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
isShowingAttachmentsLoading: attr({
|
||||
default: false,
|
||||
}),
|
||||
isTemporary: attr({
|
||||
compute() {
|
||||
return Boolean(!this.thread || this.thread.isTemporary);
|
||||
},
|
||||
}),
|
||||
saveRecord: attr(),
|
||||
scrollPanelRef: attr(),
|
||||
/**
|
||||
* Determines whether the view should reload after file changed in this chatter,
|
||||
* such as from a file upload.
|
||||
*/
|
||||
shouldReloadParentFromFileChanged: attr({
|
||||
compute() {
|
||||
return this.hasParentReloadOnAttachmentsChanged;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines the `Thread` that should be displayed by `this`.
|
||||
*/
|
||||
thread: one('Thread'),
|
||||
/**
|
||||
* Determines the id of the thread that will be displayed by `this`.
|
||||
*/
|
||||
threadId: attr(),
|
||||
/**
|
||||
* Determines the model of the thread that will be displayed by `this`.
|
||||
*/
|
||||
threadModel: attr(),
|
||||
/**
|
||||
* States the `ThreadView` displaying `this.thread`.
|
||||
*/
|
||||
threadView: one('ThreadView', {
|
||||
related: 'threadViewer.threadView',
|
||||
}),
|
||||
/**
|
||||
* Determines the `ThreadViewer` managing the display of `this.thread`.
|
||||
*/
|
||||
threadViewer: one('ThreadViewer', {
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
return {
|
||||
hasThreadView: this.hasThreadView,
|
||||
order: 'desc',
|
||||
thread: this.thread ? this.thread : clear(),
|
||||
};
|
||||
},
|
||||
inverse: 'chatter',
|
||||
}),
|
||||
topbar: one('ChatterTopbar', {
|
||||
compute() {
|
||||
return this.thread ? {} : clear();
|
||||
},
|
||||
inverse: 'chatter',
|
||||
}),
|
||||
useDragVisibleDropZone: one('UseDragVisibleDropZone', {
|
||||
default: {},
|
||||
inverse: 'chatterOwner',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
webRecord: attr(),
|
||||
createNewRecordComposerData: attr(),
|
||||
createNewRecordDeferred: attr(),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['threadId', 'threadModel'],
|
||||
methodName: '_onThreadIdOrThreadModelChanged',
|
||||
},
|
||||
{
|
||||
dependencies: ['thread.isLoadingAttachments'],
|
||||
methodName: '_onThreadIsLoadingAttachmentsChanged',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'ChatterTopbar',
|
||||
fields: {
|
||||
/**
|
||||
* Determines the label on the attachment button of the topbar.
|
||||
*/
|
||||
attachmentButtonText: attr({
|
||||
compute() {
|
||||
if (!this.chatter || !this.chatter.thread) {
|
||||
return clear();
|
||||
}
|
||||
const attachments = this.chatter.thread.allAttachments;
|
||||
if (attachments.length === 0) {
|
||||
return clear();
|
||||
}
|
||||
return attachments.length;
|
||||
},
|
||||
default: "",
|
||||
}),
|
||||
chatter: one('Chatter', {
|
||||
identifying: true,
|
||||
inverse: 'topbar',
|
||||
}),
|
||||
},
|
||||
});
|
||||
72
odoo-bringout-oca-ocb-mail/mail/static/src/models/clock.js
Normal file
72
odoo-bringout-oca-ocb-mail/mail/static/src/models/clock.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many } from '@mail/model/model_field';
|
||||
|
||||
/**
|
||||
* Models a record that provides the current date, updated at a given frequency.
|
||||
*/
|
||||
registerModel({
|
||||
name: 'Clock',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
// The date is set here rather than via a default value so that the
|
||||
// date set at first is the time of the record creation, and not the
|
||||
// time of the model initialization.
|
||||
this.update({ date: new Date() });
|
||||
},
|
||||
_willDelete() {
|
||||
this.messaging.browser.clearInterval(this.tickInterval);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeWatchers() {
|
||||
if (this.watchers.length === 0) {
|
||||
this.delete();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onInterval() {
|
||||
this.update({ date: new Date() });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* A Date object set to the current date at the time the record is
|
||||
* created, then updated at every tick.
|
||||
*/
|
||||
date: attr(),
|
||||
/**
|
||||
* An integer representing the frequency in milliseconds at which `date`
|
||||
* must be recomputed.
|
||||
*/
|
||||
frequency: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
tickInterval: attr({
|
||||
compute() {
|
||||
return this.messaging.browser.setInterval(this._onInterval, this.frequency);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* The records that are making use of this clock.
|
||||
*
|
||||
* The clock self-destructs when there are no more watchers.
|
||||
*/
|
||||
watchers: many('ClockWatcher', {
|
||||
inverse: 'clock',
|
||||
isCausal: true,
|
||||
}),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['watchers'],
|
||||
methodName: '_onChangeWatchers',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
/**
|
||||
* Models a record that makes use of a clock.
|
||||
*/
|
||||
registerModel({
|
||||
name: 'ClockWatcher',
|
||||
identifyingMode: 'xor',
|
||||
fields: {
|
||||
activityListViewItemOwner: one('ActivityListViewItem', {
|
||||
identifying: true,
|
||||
inverse: 'clockWatcher',
|
||||
}),
|
||||
activityViewOwner: one('ActivityView', {
|
||||
identifying: true,
|
||||
inverse: 'clockWatcher',
|
||||
}),
|
||||
clock: one('Clock', {
|
||||
inverse: 'watchers',
|
||||
required: true,
|
||||
}),
|
||||
messageViewOwner: one('MessageView', {
|
||||
identifying: true,
|
||||
inverse: 'clockWatcher',
|
||||
}),
|
||||
},
|
||||
});
|
||||
195
odoo-bringout-oca-ocb-mail/mail/static/src/models/composer.js
Normal file
195
odoo-bringout-oca-ocb-mail/mail/static/src/models/composer.js
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'Composer',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_reset() {
|
||||
this.update({
|
||||
attachments: clear(),
|
||||
cannedResponses: clear(),
|
||||
rawMentionedChannels: clear(),
|
||||
rawMentionedPartners: clear(),
|
||||
textInputContent: clear(),
|
||||
textInputCursorEnd: clear(),
|
||||
textInputCursorStart: clear(),
|
||||
textInputSelectionDirection: clear(),
|
||||
});
|
||||
for (const composerView of this.composerViews) {
|
||||
composerView.update({ hasToRestoreContent: true });
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activeThread: one('Thread', {
|
||||
compute() {
|
||||
if (this.messageViewInEditing && this.messageViewInEditing.message && this.messageViewInEditing.message.originThread) {
|
||||
return this.messageViewInEditing.message.originThread;
|
||||
}
|
||||
if (this.thread) {
|
||||
return this.thread;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* States which attachments are currently being created in this composer.
|
||||
*/
|
||||
attachments: many('Attachment', {
|
||||
inverse: 'composer',
|
||||
}),
|
||||
canPostMessage: attr({
|
||||
compute() {
|
||||
if (this.thread && !this.textInputContent && this.attachments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return !this.hasUploadingAttachment && !this.isPostingMessage;
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
cannedResponses: many('CannedResponse'),
|
||||
composerViews: many('ComposerView', {
|
||||
inverse: 'composer',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* This field determines whether some attachments linked to this
|
||||
* composer are being uploaded.
|
||||
*/
|
||||
hasUploadingAttachment: attr({
|
||||
compute() {
|
||||
return this.attachments.some(attachment => attachment.isUploading);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* If true composer will log a note, else a comment will be posted.
|
||||
*/
|
||||
isLog: attr({
|
||||
default: true,
|
||||
}),
|
||||
/**
|
||||
* Determines whether a post_message request is currently pending.
|
||||
*/
|
||||
isPostingMessage: attr(),
|
||||
mentionedChannels: many('Thread', {
|
||||
/**
|
||||
* Detects if mentioned channels are still in the composer text input content
|
||||
* and removes them if not.
|
||||
*/
|
||||
compute() {
|
||||
const mentionedChannels = [];
|
||||
// ensure the same mention is not used multiple times if multiple
|
||||
// channels have the same name
|
||||
const namesIndex = {};
|
||||
for (const channel of this.rawMentionedChannels) {
|
||||
const fromIndex = namesIndex[channel.name] !== undefined
|
||||
? namesIndex[channel.name] + 1 :
|
||||
0;
|
||||
const index = this.textInputContent.indexOf(`#${channel.name}`, fromIndex);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
namesIndex[channel.name] = index;
|
||||
mentionedChannels.push(channel);
|
||||
}
|
||||
return mentionedChannels;
|
||||
},
|
||||
}),
|
||||
mentionedPartners: many('Partner', {
|
||||
/**
|
||||
* Detects if mentioned partners are still in the composer text input content
|
||||
* and removes them if not.
|
||||
*/
|
||||
compute() {
|
||||
const mentionedPartners = [];
|
||||
// ensure the same mention is not used multiple times if multiple
|
||||
// partners have the same name
|
||||
const namesIndex = {};
|
||||
for (const partner of this.rawMentionedPartners) {
|
||||
const fromIndex = namesIndex[partner.name] !== undefined
|
||||
? namesIndex[partner.name] + 1 :
|
||||
0;
|
||||
const index = this.textInputContent.indexOf(`@${partner.name}`, fromIndex);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
namesIndex[partner.name] = index;
|
||||
mentionedPartners.push(partner);
|
||||
}
|
||||
return mentionedPartners;
|
||||
},
|
||||
}),
|
||||
messageViewInEditing: one('MessageView', {
|
||||
identifying: true,
|
||||
inverse: 'composerForEditing',
|
||||
}),
|
||||
/**
|
||||
* Placeholder displayed in the composer textarea when it's empty
|
||||
*/
|
||||
placeholder: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return "";
|
||||
}
|
||||
if (this.thread.channel) {
|
||||
if (this.thread.channel.correspondent) {
|
||||
return sprintf(this.env._t("Message %s..."), this.thread.channel.correspondent.nameOrDisplayName);
|
||||
}
|
||||
return sprintf(this.env._t("Message #%s..."), this.thread.displayName);
|
||||
}
|
||||
if (this.isLog) {
|
||||
return this.env._t("Log an internal note...");
|
||||
}
|
||||
return this.env._t("Send a message to followers...");
|
||||
},
|
||||
}),
|
||||
rawMentionedChannels: many('Thread'),
|
||||
rawMentionedPartners: many('Partner'),
|
||||
/**
|
||||
* Determines the extra `Partner` (on top of existing followers)
|
||||
* that will receive the message being composed by `this`, and that will
|
||||
* also be added as follower of `this.activeThread`.
|
||||
*/
|
||||
recipients: many('Partner', {
|
||||
compute() {
|
||||
const recipients = [...this.mentionedPartners];
|
||||
if (this.activeThread && !this.isLog) {
|
||||
for (const recipient of this.activeThread.suggestedRecipientInfoList) {
|
||||
if (recipient.partner && recipient.isSelected) {
|
||||
recipients.push(recipient.partner);
|
||||
}
|
||||
}
|
||||
}
|
||||
return recipients;
|
||||
},
|
||||
}),
|
||||
textInputContent: attr({
|
||||
default: "",
|
||||
}),
|
||||
textInputCursorEnd: attr({
|
||||
default: 0,
|
||||
}),
|
||||
textInputCursorStart: attr({
|
||||
default: 0,
|
||||
}),
|
||||
textInputSelectionDirection: attr({
|
||||
default: "none",
|
||||
}),
|
||||
/**
|
||||
* States the thread which this composer represents the state (if any).
|
||||
*/
|
||||
thread: one('Thread', {
|
||||
identifying: true,
|
||||
inverse: 'composer',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ComposerSuggestable',
|
||||
identifyingMode: 'xor',
|
||||
fields: {
|
||||
cannedResponse: one('CannedResponse', {
|
||||
identifying: true,
|
||||
inverse: 'suggestable',
|
||||
}),
|
||||
channelCommand: one('ChannelCommand', {
|
||||
identifying: true,
|
||||
inverse: 'suggestable',
|
||||
}),
|
||||
composerSuggestionListViewExtraComposerSuggestionViewItems: many('ComposerSuggestionListViewExtraComposerSuggestionViewItem', {
|
||||
inverse: 'suggestable',
|
||||
}),
|
||||
composerSuggestionListViewMainComposerSuggestionViewItems: many('ComposerSuggestionListViewMainComposerSuggestionViewItem', {
|
||||
inverse: 'suggestable',
|
||||
}),
|
||||
partner: one('Partner', {
|
||||
identifying: true,
|
||||
inverse: 'suggestable',
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
identifying: true,
|
||||
inverse: 'suggestable',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'ComposerSuggestedRecipientListView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickShowLess(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ hasShowMoreButton: false });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickShowMore(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ hasShowMoreButton: true });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
composerSuggestedRecipientViews: many('ComposerSuggestedRecipientView', {
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
if (this.hasShowMoreButton) {
|
||||
return this.thread.suggestedRecipientInfoList.map(suggestedRecipientInfo => ({ suggestedRecipientInfo }));
|
||||
} else {
|
||||
return this.thread.suggestedRecipientInfoList.slice(0, 3).map(suggestedRecipientInfo => ({ suggestedRecipientInfo }));
|
||||
}
|
||||
},
|
||||
inverse: 'composerSuggestedRecipientListViewOwner',
|
||||
}),
|
||||
composerViewOwner: one('ComposerView', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestedRecipientListView',
|
||||
}),
|
||||
hasShowMoreButton: attr({
|
||||
default: false,
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
compute() {
|
||||
return this.composerViewOwner.composer.activeThread;
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ComposerSuggestedRecipientView',
|
||||
fields: {
|
||||
composerSuggestedRecipientListViewOwner: one('ComposerSuggestedRecipientListView', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestedRecipientViews',
|
||||
}),
|
||||
suggestedRecipientInfo: one('SuggestedRecipientInfo', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestedRecipientViews',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ComposerSuggestionListView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Sets the first suggestion as active. Main and extra records are
|
||||
* considered together.
|
||||
*/
|
||||
setFirstSuggestionViewActive() {
|
||||
const firstSuggestionView = this.suggestionViews[0];
|
||||
this.update({ rawActiveSuggestionView: firstSuggestionView });
|
||||
},
|
||||
/**
|
||||
* Sets the last suggestion as active. Main and extra records are
|
||||
* considered together.
|
||||
*/
|
||||
setLastSuggestionViewActive() {
|
||||
const { length, [length - 1]: lastSuggestionView } = this.suggestionViews;
|
||||
this.update({ rawActiveSuggestionView: lastSuggestionView });
|
||||
},
|
||||
/**
|
||||
* Sets the next suggestion as active. Main and extra records are
|
||||
* considered together.
|
||||
*/
|
||||
setNextSuggestionViewActive() {
|
||||
const activeElementIndex = this.suggestionViews.findIndex(
|
||||
suggestion => suggestion === this.activeSuggestionView
|
||||
);
|
||||
if (activeElementIndex === this.suggestionViews.length - 1) {
|
||||
// loop when reaching the end of the list
|
||||
this.setFirstSuggestionViewActive();
|
||||
return;
|
||||
}
|
||||
const nextSuggestionView = this.suggestionViews[activeElementIndex + 1];
|
||||
this.update({ rawActiveSuggestionView: nextSuggestionView });
|
||||
},
|
||||
/**
|
||||
* Sets the previous suggestion as active. Main and extra records are
|
||||
* considered together.
|
||||
*/
|
||||
setPreviousSuggestionViewActive() {
|
||||
const activeElementIndex = this.suggestionViews.findIndex(
|
||||
suggestion => suggestion === this.activeSuggestionView
|
||||
);
|
||||
if (activeElementIndex === 0) {
|
||||
// loop when reaching the start of the list
|
||||
this.setLastSuggestionViewActive();
|
||||
return;
|
||||
}
|
||||
const previousSuggestionView = this.suggestionViews[activeElementIndex - 1];
|
||||
this.update({ rawActiveSuggestionView: previousSuggestionView });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Determines the suggestion that is currently active. This suggestion
|
||||
* is highlighted in the UI and it will be selected when the
|
||||
* suggestion is confirmed by the user.
|
||||
*/
|
||||
activeSuggestionView: one('ComposerSuggestionView', {
|
||||
compute() {
|
||||
if (this.suggestionViews.includes(this.rawActiveSuggestionView)) {
|
||||
return this.rawActiveSuggestionView;
|
||||
}
|
||||
const firstSuggestionView = this.suggestionViews[0];
|
||||
return firstSuggestionView;
|
||||
},
|
||||
inverse: 'composerSuggestionListViewOwnerAsActiveSuggestionView',
|
||||
}),
|
||||
composerSuggestionListViewExtraComposerSuggestionViewItems: many('ComposerSuggestionListViewExtraComposerSuggestionViewItem', {
|
||||
compute() {
|
||||
return this.composerViewOwner.extraSuggestions.map(suggestable => ({ suggestable }));
|
||||
},
|
||||
inverse: 'composerSuggestionListViewOwner',
|
||||
}),
|
||||
composerSuggestionListViewMainComposerSuggestionViewItems: many('ComposerSuggestionListViewMainComposerSuggestionViewItem', {
|
||||
compute() {
|
||||
return this.composerViewOwner.mainSuggestions.map(suggestable => ({ suggestable }));
|
||||
},
|
||||
inverse: 'composerSuggestionListViewOwner',
|
||||
}),
|
||||
composerViewOwner: one('ComposerView', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestionListView',
|
||||
}),
|
||||
/**
|
||||
* Determines whether the currently active suggestion should be scrolled
|
||||
* into view.
|
||||
*/
|
||||
hasToScrollToActiveSuggestionView: attr({
|
||||
default: false,
|
||||
}),
|
||||
rawActiveSuggestionView: one('ComposerSuggestionView'),
|
||||
suggestionViews: many('ComposerSuggestionView', {
|
||||
compute() {
|
||||
const mainSuggestionViews = this.composerSuggestionListViewMainComposerSuggestionViewItems.map(item => item.composerSuggestionView);
|
||||
const extraSuggestionViews = this.composerSuggestionListViewExtraComposerSuggestionViewItems.map(item => item.composerSuggestionView);
|
||||
return mainSuggestionViews.concat(extraSuggestionViews);
|
||||
},
|
||||
})
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
/**
|
||||
* Models a relation between a ComposerSuggestionListView and a
|
||||
* ComposerSuggestionView where suggestable is used as iterating field for extra
|
||||
* suggestions.
|
||||
*/
|
||||
registerModel({
|
||||
name: 'ComposerSuggestionListViewExtraComposerSuggestionViewItem',
|
||||
fields: {
|
||||
composerSuggestionListViewOwner: one('ComposerSuggestionListView', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestionListViewExtraComposerSuggestionViewItems',
|
||||
}),
|
||||
composerSuggestionView: one('ComposerSuggestionView', {
|
||||
default: {},
|
||||
inverse: 'composerSuggestionListViewExtraComposerSuggestionViewItemOwner',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
suggestable: one('ComposerSuggestable', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestionListViewExtraComposerSuggestionViewItems',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
/**
|
||||
* Models a relation between a ComposerSuggestionListView and a
|
||||
* ComposerSuggestionView where suggestable is used as iterating field for main
|
||||
* suggestions.
|
||||
*/
|
||||
registerModel({
|
||||
name: 'ComposerSuggestionListViewMainComposerSuggestionViewItem',
|
||||
fields: {
|
||||
composerSuggestionListViewOwner: one('ComposerSuggestionListView', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestionListViewMainComposerSuggestionViewItems',
|
||||
}),
|
||||
composerSuggestionView: one('ComposerSuggestionView', {
|
||||
default: {},
|
||||
inverse: 'composerSuggestionListViewMainComposerSuggestionViewItemOwner',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
suggestable: one('ComposerSuggestable', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestionListViewMainComposerSuggestionViewItems',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { sprintf } from '@web/core/utils/strings';
|
||||
|
||||
/**
|
||||
* Models a suggestion in the composer suggestion.
|
||||
*
|
||||
* For instance, to mention a partner, can type "@" and some keyword,
|
||||
* and display suggested partners to mention.
|
||||
*/
|
||||
registerModel({
|
||||
name: 'ComposerSuggestionView',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
ev.preventDefault();
|
||||
this.composerSuggestionListViewOwner.update({ rawActiveSuggestionView: this });
|
||||
const composerViewOwner = this.composerSuggestionListViewOwner.composerViewOwner;
|
||||
composerViewOwner.insertSuggestion();
|
||||
composerViewOwner.closeSuggestions();
|
||||
composerViewOwner.update({ doFocus: true });
|
||||
},
|
||||
onComponentUpdate() {
|
||||
if (
|
||||
this.component.root.el &&
|
||||
this.composerSuggestionListViewOwner.hasToScrollToActiveSuggestionView &&
|
||||
this.composerSuggestionListViewOwnerAsActiveSuggestionView
|
||||
) {
|
||||
this.component.root.el.scrollIntoView({ block: 'center' });
|
||||
this.composerSuggestionListViewOwner.update({ hasToScrollToActiveSuggestionView: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
component: attr(),
|
||||
composerSuggestionListViewOwner: one('ComposerSuggestionListView', {
|
||||
compute() {
|
||||
if (this.composerSuggestionListViewExtraComposerSuggestionViewItemOwner) {
|
||||
return this.composerSuggestionListViewExtraComposerSuggestionViewItemOwner.composerSuggestionListViewOwner;
|
||||
}
|
||||
if (this.composerSuggestionListViewMainComposerSuggestionViewItemOwner) {
|
||||
return this.composerSuggestionListViewMainComposerSuggestionViewItemOwner.composerSuggestionListViewOwner;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
composerSuggestionListViewOwnerAsActiveSuggestionView: one('ComposerSuggestionListView', {
|
||||
inverse: 'activeSuggestionView',
|
||||
}),
|
||||
composerSuggestionListViewExtraComposerSuggestionViewItemOwner: one('ComposerSuggestionListViewExtraComposerSuggestionViewItem', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestionView',
|
||||
}),
|
||||
composerSuggestionListViewMainComposerSuggestionViewItemOwner: one('ComposerSuggestionListViewMainComposerSuggestionViewItem', {
|
||||
identifying: true,
|
||||
inverse: 'composerSuggestionView',
|
||||
}),
|
||||
/**
|
||||
* The text that identifies this suggestion in a mention.
|
||||
*/
|
||||
mentionText: attr({
|
||||
compute() {
|
||||
if (!this.suggestable) {
|
||||
return clear();
|
||||
}
|
||||
if (this.suggestable.cannedResponse) {
|
||||
return this.suggestable.cannedResponse.substitution;
|
||||
}
|
||||
if (this.suggestable.channelCommand) {
|
||||
return this.suggestable.channelCommand.name;
|
||||
}
|
||||
if (this.suggestable.partner) {
|
||||
return this.suggestable.partner.name;
|
||||
}
|
||||
if (this.suggestable.thread) {
|
||||
return this.suggestable.thread.name;
|
||||
}
|
||||
},
|
||||
}),
|
||||
personaImStatusIconView: one('PersonaImStatusIconView', {
|
||||
compute() {
|
||||
return this.suggestable && this.suggestable.partner && this.suggestable.partner.isImStatusSet ? {} : clear();
|
||||
},
|
||||
inverse: 'composerSuggestionViewOwner',
|
||||
}),
|
||||
suggestable: one('ComposerSuggestable', {
|
||||
compute() {
|
||||
if (this.composerSuggestionListViewExtraComposerSuggestionViewItemOwner) {
|
||||
return this.composerSuggestionListViewExtraComposerSuggestionViewItemOwner.suggestable;
|
||||
}
|
||||
if (this.composerSuggestionListViewMainComposerSuggestionViewItemOwner) {
|
||||
return this.composerSuggestionListViewMainComposerSuggestionViewItemOwner.suggestable;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* Descriptive title for this suggestion. Useful to be able to
|
||||
* read both parts when they are overflowing the UI.
|
||||
*/
|
||||
title: attr({
|
||||
compute() {
|
||||
if (!this.suggestable) {
|
||||
return clear();
|
||||
}
|
||||
if (this.suggestable.cannedResponse) {
|
||||
return sprintf("%s: %s", this.suggestable.cannedResponse.source, this.suggestable.cannedResponse.substitution);
|
||||
}
|
||||
if (this.suggestable.thread) {
|
||||
return this.suggestable.thread.name;
|
||||
}
|
||||
if (this.suggestable.channelCommand) {
|
||||
return sprintf("%s: %s", this.suggestable.channelCommand.name, this.suggestable.channelCommand.help);
|
||||
}
|
||||
if (this.suggestable.partner) {
|
||||
if (this.suggestable.partner.email) {
|
||||
return sprintf("%s (%s)", this.suggestable.partner.nameOrDisplayName, this.suggestable.partner.email);
|
||||
}
|
||||
return this.suggestable.partner.nameOrDisplayName;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: "",
|
||||
}),
|
||||
},
|
||||
});
|
||||
1495
odoo-bringout-oca-ocb-mail/mail/static/src/models/composer_view.js
Normal file
1495
odoo-bringout-oca-ocb-mail/mail/static/src/models/composer_view.js
Normal file
File diff suppressed because it is too large
Load diff
24
odoo-bringout-oca-ocb-mail/mail/static/src/models/country.js
Normal file
24
odoo-bringout-oca-ocb-mail/mail/static/src/models/country.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'Country',
|
||||
fields: {
|
||||
code: attr(),
|
||||
flagUrl: attr({
|
||||
compute() {
|
||||
if (!this.code) {
|
||||
return clear();
|
||||
}
|
||||
return `/base/static/img/country_flags/${this.code}.png`;
|
||||
},
|
||||
}),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
name: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'DeleteMessageConfirmView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Returns whether the given html element is inside this delete message confirm view.
|
||||
*
|
||||
* @param {Element} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
containsElement(element) {
|
||||
return Boolean(this.component && this.component.root.el && this.component.root.el.contains(element));
|
||||
},
|
||||
onClickCancel() {
|
||||
this.dialogOwner.delete();
|
||||
},
|
||||
onClickDelete() {
|
||||
this.message.updateContent({
|
||||
attachment_ids: [],
|
||||
attachment_tokens: [],
|
||||
body: '',
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
component: attr(),
|
||||
dialogOwner: one('Dialog', {
|
||||
identifying: true,
|
||||
inverse: 'deleteMessageConfirmView',
|
||||
}),
|
||||
message: one('Message', {
|
||||
compute() {
|
||||
return this.dialogOwner.messageActionViewOwnerAsDeleteConfirm.messageAction.messageActionListOwner.message;
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
/**
|
||||
* Determines the message view that this delete message confirm view
|
||||
* will use to display this message.
|
||||
*/
|
||||
messageView: one('MessageView', {
|
||||
compute() {
|
||||
return this.message ? { message: this.message } : clear();
|
||||
},
|
||||
inverse: 'deleteMessageConfirmViewOwner',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
215
odoo-bringout-oca-ocb-mail/mail/static/src/models/dialog.js
Normal file
215
odoo-bringout-oca-ocb-mail/mail/static/src/models/dialog.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'Dialog',
|
||||
identifyingMode: 'xor',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
document.addEventListener('click', this._onClickGlobal, true);
|
||||
document.addEventListener('keydown', this._onKeydownGlobal);
|
||||
},
|
||||
_willDelete() {
|
||||
document.removeEventListener('click', this._onClickGlobal, true);
|
||||
document.removeEventListener('keydown', this._onKeydownGlobal);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {Element} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasElementInContent(element) {
|
||||
return Boolean(this.record && this.record.containsElement(element));
|
||||
},
|
||||
/**
|
||||
* Closes the dialog when clicking outside.
|
||||
* Does not work with attachment viewer because it takes the whole space.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickGlobal(ev) {
|
||||
if (this.hasElementInContent(ev.target)) {
|
||||
return;
|
||||
}
|
||||
if (!this.isCloseable) {
|
||||
return;
|
||||
}
|
||||
this.delete();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeydownGlobal(ev) {
|
||||
if (ev.key === 'Escape') {
|
||||
this.delete();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
attachmentCardOwnerAsAttachmentDeleteConfirm: one('AttachmentCard', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentDeleteConfirmDialog',
|
||||
}),
|
||||
attachmentDeleteConfirmView: one('AttachmentDeleteConfirmView', {
|
||||
compute() {
|
||||
if (this.attachmentCardOwnerAsAttachmentDeleteConfirm) {
|
||||
return {};
|
||||
}
|
||||
if (this.attachmentImageOwnerAsAttachmentDeleteConfirm) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'dialogOwner',
|
||||
}),
|
||||
attachmentImageOwnerAsAttachmentDeleteConfirm: one('AttachmentImage', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentDeleteConfirmDialog',
|
||||
}),
|
||||
attachmentListOwnerAsAttachmentView: one('AttachmentList', {
|
||||
identifying: true,
|
||||
inverse: 'attachmentListViewDialog',
|
||||
}),
|
||||
attachmentViewer: one('AttachmentViewer', {
|
||||
compute() {
|
||||
if (this.attachmentListOwnerAsAttachmentView) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'dialogOwner',
|
||||
}),
|
||||
backgroundOpacity: attr({
|
||||
compute() {
|
||||
if (this.attachmentViewer) {
|
||||
return 0.7;
|
||||
}
|
||||
return 0.5;
|
||||
},
|
||||
}),
|
||||
componentClassName: attr({
|
||||
compute() {
|
||||
if (this.attachmentDeleteConfirmView) {
|
||||
return 'o_Dialog_componentMediumSize align-self-start mt-5';
|
||||
}
|
||||
if (this.deleteMessageConfirmView) {
|
||||
return 'o_Dialog_componentLargeSize align-self-start mt-5';
|
||||
}
|
||||
if (this.linkPreviewDeleteConfirmView) {
|
||||
return 'o_Dialog_componentMediumSize align-self-start mt-5';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
}),
|
||||
componentName: attr({
|
||||
compute() {
|
||||
if (this.attachmentViewer) {
|
||||
return 'AttachmentViewer';
|
||||
}
|
||||
if (this.attachmentDeleteConfirmView) {
|
||||
return 'AttachmentDeleteConfirm';
|
||||
}
|
||||
if (this.deleteMessageConfirmView) {
|
||||
return 'DeleteMessageConfirm';
|
||||
}
|
||||
if (this.followerSubtypeList) {
|
||||
return 'FollowerSubtypeList';
|
||||
}
|
||||
if (this.linkPreviewDeleteConfirmView) {
|
||||
return 'LinkPreviewDeleteConfirmView';
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
deleteMessageConfirmView: one('DeleteMessageConfirmView', {
|
||||
compute() {
|
||||
return this.messageActionViewOwnerAsDeleteConfirm ? {} : clear();
|
||||
},
|
||||
inverse: 'dialogOwner',
|
||||
}),
|
||||
linkPreviewAsideViewOwnerAsLinkPreviewDeleteConfirm: one('LinkPreviewAsideView', {
|
||||
inverse: 'linkPreviewDeleteConfirmDialog',
|
||||
readonly: true,
|
||||
}),
|
||||
linkPreviewDeleteConfirmView: one('LinkPreviewDeleteConfirmView', {
|
||||
compute() {
|
||||
return this.linkPreviewAsideViewOwnerAsLinkPreviewDeleteConfirm ? {} : clear();
|
||||
},
|
||||
inverse: 'dialogOwner',
|
||||
}),
|
||||
followerOwnerAsSubtypeList: one('Follower', {
|
||||
identifying: true,
|
||||
inverse: 'followerSubtypeListDialog',
|
||||
}),
|
||||
followerSubtypeList: one('FollowerSubtypeList', {
|
||||
compute() {
|
||||
return this.followerOwnerAsSubtypeList ? {} : clear();
|
||||
},
|
||||
inverse: 'dialogOwner',
|
||||
}),
|
||||
isCloseable: attr({
|
||||
compute() {
|
||||
if (this.attachmentViewer) {
|
||||
/**
|
||||
* Prevent closing the dialog when clicking on the mask when the user is
|
||||
* currently dragging the image.
|
||||
*/
|
||||
return !this.attachmentViewer.isDragging;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
default: true,
|
||||
}),
|
||||
manager: one('DialogManager', {
|
||||
compute() {
|
||||
if (this.messaging.dialogManager) {
|
||||
return this.messaging.dialogManager;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'dialogs',
|
||||
}),
|
||||
messageActionViewOwnerAsDeleteConfirm: one('MessageActionView', {
|
||||
identifying: true,
|
||||
inverse: 'deleteConfirmDialog',
|
||||
}),
|
||||
/**
|
||||
* Content of dialog that is directly linked to a record that models
|
||||
* a UI component, such as AttachmentViewer. These records must be
|
||||
* created from @see `DialogManager:open()`.
|
||||
*/
|
||||
record: one('Record', {
|
||||
compute() {
|
||||
if (this.attachmentViewer) {
|
||||
return this.attachmentViewer;
|
||||
}
|
||||
if (this.attachmentDeleteConfirmView) {
|
||||
return this.attachmentDeleteConfirmView;
|
||||
}
|
||||
if (this.deleteMessageConfirmView) {
|
||||
return this.deleteMessageConfirmView;
|
||||
}
|
||||
if (this.linkPreviewDeleteConfirmView) {
|
||||
return this.linkPreviewDeleteConfirmView;
|
||||
}
|
||||
if (this.followerSubtypeList) {
|
||||
return this.followerSubtypeList;
|
||||
}
|
||||
},
|
||||
isCausal: true,
|
||||
required: true,
|
||||
}),
|
||||
style: attr({
|
||||
compute() {
|
||||
return `background-color: rgba(0, 0, 0, ${this.backgroundOpacity});`;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { many } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'DialogManager',
|
||||
recordMethods: {
|
||||
onComponentUpdate() {
|
||||
if (this.dialogs.length > 0) {
|
||||
document.body.classList.add('modal-open');
|
||||
} else {
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
// FIXME: dependent on implementation that uses insert order in relations!!
|
||||
dialogs: many('Dialog', {
|
||||
inverse: 'manager',
|
||||
isCausal: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
339
odoo-bringout-oca-ocb-mail/mail/static/src/models/discuss.js
Normal file
339
odoo-bringout-oca-ocb-mail/mail/static/src/models/discuss.js
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import { escape, sprintf } from '@web/core/utils/strings';
|
||||
|
||||
registerModel({
|
||||
name: 'Discuss',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Close the discuss app. Should reset its internal state.
|
||||
*/
|
||||
close() {
|
||||
this.update({ discussView: clear() });
|
||||
},
|
||||
focus() {
|
||||
if (this.threadView && this.threadView.composerView) {
|
||||
this.threadView.composerView.update({ doFocus: true });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Event} ev
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
async handleAddChannelAutocompleteSelect(ev, ui) {
|
||||
// Necessary in order to prevent AutocompleteSelect event's default
|
||||
// behaviour as html tags visible for a split second in text area
|
||||
ev.preventDefault();
|
||||
const name = this.discussView.addingChannelValue;
|
||||
this.discussView.clearIsAddingItem();
|
||||
if (ui.item.create) {
|
||||
const channel = await this.messaging.models['Thread'].performRpcCreateChannel({
|
||||
name,
|
||||
group_id: this.messaging.internalUserGroupId,
|
||||
});
|
||||
channel.open();
|
||||
} else {
|
||||
const channel = this.messaging.models['Thread'].insert({
|
||||
id: ui.item.id,
|
||||
model: 'mail.channel',
|
||||
});
|
||||
await channel.join();
|
||||
// Channel must be pinned immediately to be able to open it before
|
||||
// the result of join is received on the bus.
|
||||
channel.update({ isServerPinned: true });
|
||||
channel.open();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
async handleAddChannelAutocompleteSource(req, res) {
|
||||
this.discussView.update({ addingChannelValue: req.term });
|
||||
const threads = await this.messaging.models['Thread'].searchChannelsToOpen({ limit: 10, searchTerm: req.term });
|
||||
const items = threads.map((thread) => {
|
||||
const escapedName = escape(thread.name);
|
||||
return {
|
||||
id: thread.id,
|
||||
label: escapedName,
|
||||
value: escapedName,
|
||||
};
|
||||
});
|
||||
const escapedValue = escape(req.term);
|
||||
// XDU FIXME could use a component but be careful with owl's
|
||||
// renderToString https://github.com/odoo/owl/issues/708
|
||||
items.push({
|
||||
create: true,
|
||||
escapedValue,
|
||||
label: sprintf(
|
||||
`<strong>${this.env._t('Create %s')}</strong>`,
|
||||
`<em><span class="fa fa-hashtag"/>${escapedValue}</em>`,
|
||||
),
|
||||
});
|
||||
res(items);
|
||||
},
|
||||
/**
|
||||
* @param {Event} ev
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
handleAddChatAutocompleteSelect(ev, ui) {
|
||||
this.messaging.openChat({ partnerId: ui.item.id });
|
||||
this.discussView.clearIsAddingItem();
|
||||
},
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
handleAddChatAutocompleteSource(req, res) {
|
||||
const value = escape(req.term);
|
||||
this.messaging.models['Partner'].imSearch({
|
||||
callback: partners => {
|
||||
const suggestions = partners.map(partner => {
|
||||
return {
|
||||
id: partner.id,
|
||||
value: partner.nameOrDisplayName,
|
||||
label: partner.nameOrDisplayName,
|
||||
};
|
||||
});
|
||||
res(_.sortBy(suggestions, 'label'));
|
||||
},
|
||||
keyword: value,
|
||||
limit: 10,
|
||||
});
|
||||
},
|
||||
open() {
|
||||
this.update({ discussView: {} });
|
||||
},
|
||||
/**
|
||||
* Opens thread from init active id if the thread exists.
|
||||
*/
|
||||
openInitThread() {
|
||||
const [model, id] = typeof this.initActiveId === 'number'
|
||||
? ['mail.channel', this.initActiveId]
|
||||
: this.initActiveId.split('_');
|
||||
const thread = this.messaging.models['Thread'].findFromIdentifyingData({
|
||||
id: model !== 'mail.box' ? Number(id) : id,
|
||||
model,
|
||||
});
|
||||
if (!thread) {
|
||||
return;
|
||||
}
|
||||
thread.open();
|
||||
if (this.messaging.device.isSmall && thread.channel && thread.channel.channel_type) {
|
||||
this.update({ activeMobileNavbarTabId: thread.channel.channel_type });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Opens the given thread in Discuss, and opens Discuss if necessary.
|
||||
*
|
||||
* @param {Thread} thread
|
||||
* @param {Object} [param1={}]
|
||||
* @param {Boolean} [param1.focus]
|
||||
*/
|
||||
async openThread(thread, { focus } = {}) {
|
||||
this.update({ thread });
|
||||
if (focus !== undefined ? focus : !this.messaging.device.isMobileDevice) {
|
||||
this.focus();
|
||||
}
|
||||
if (!this.discussView) {
|
||||
this.env.services.action.doAction(
|
||||
'mail.action_discuss',
|
||||
{
|
||||
name: this.env._t("Discuss"),
|
||||
active_id: this.threadToActiveId(this),
|
||||
clearBreadcrumbs: false,
|
||||
on_reverse_breadcrumb: () => this.close(), // this is useless, close is called by destroy anyway
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Thread} thread
|
||||
* @returns {string}
|
||||
*/
|
||||
threadToActiveId(thread) {
|
||||
return `${thread.model}_${thread.id}`;
|
||||
},
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
onInputQuickSearch(value) {
|
||||
// Opens all categories only when user starts to search from empty search value.
|
||||
if (!this.sidebarQuickSearchValue) {
|
||||
this.categoryChat.open();
|
||||
this.categoryChannel.open();
|
||||
}
|
||||
this.update({ sidebarQuickSearchValue: value });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activeId: attr({
|
||||
compute() {
|
||||
if (!this.activeThread) {
|
||||
return clear();
|
||||
}
|
||||
return this.threadToActiveId(this.activeThread);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Active mobile navbar tab, either 'mailbox', 'chat', or 'channel'.
|
||||
*/
|
||||
activeMobileNavbarTabId: attr({
|
||||
default: 'mailbox',
|
||||
}),
|
||||
/**
|
||||
* Determines the `Thread` that should be displayed by `this`.
|
||||
*/
|
||||
activeThread: one('Thread', {
|
||||
/**
|
||||
* Only mailboxes and pinned channels are allowed in Discuss.
|
||||
*/
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
if (this.thread.channel && this.thread.isPinned) {
|
||||
return this.thread;
|
||||
}
|
||||
if (this.thread.mailbox) {
|
||||
return this.thread;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
addChannelInputPlaceholder: attr({
|
||||
compute() {
|
||||
return this.env._t("Create or search channel...");
|
||||
},
|
||||
}),
|
||||
addChatInputPlaceholder: attr({
|
||||
compute() {
|
||||
return this.env._t("Search user...");
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Discuss sidebar category for `channel` type channel threads.
|
||||
*/
|
||||
categoryChannel: one('DiscussSidebarCategory', {
|
||||
default: {},
|
||||
inverse: 'discussAsChannel',
|
||||
}),
|
||||
/**
|
||||
* Discuss sidebar category for `chat` type channel threads.
|
||||
*/
|
||||
categoryChat: one('DiscussSidebarCategory', {
|
||||
default: {},
|
||||
inverse: 'discussAsChat',
|
||||
}),
|
||||
discussView: one('DiscussView', {
|
||||
inverse: 'discuss',
|
||||
}),
|
||||
/**
|
||||
* Determines whether `this.thread` should be displayed.
|
||||
*/
|
||||
hasThreadView: attr({
|
||||
compute() {
|
||||
if (!this.activeThread || !this.discussView) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.messaging.device.isSmall &&
|
||||
(
|
||||
this.activeMobileNavbarTabId !== 'mailbox' ||
|
||||
!this.activeThread.mailbox
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Formatted init thread on opening discuss for the first time,
|
||||
* when no active thread is defined. Useful to set a thread to
|
||||
* open without knowing its local id in advance.
|
||||
* Support two formats:
|
||||
* {string} <threadModel>_<threadId>
|
||||
* {int} <channelId> with default model of 'mail.channel'
|
||||
*/
|
||||
initActiveId: attr({
|
||||
default: 'mail.box_inbox',
|
||||
}),
|
||||
/**
|
||||
* Determines if the logic for opening a thread via the `initActiveId`
|
||||
* has been processed. This is necessary to ensure that this only
|
||||
* happens once.
|
||||
*/
|
||||
isInitThreadHandled: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* The menu_id of discuss app, received on mail/init_messaging and
|
||||
* used to open discuss from elsewhere.
|
||||
*/
|
||||
menu_id: attr({
|
||||
default: null,
|
||||
}),
|
||||
notificationListView: one('NotificationListView', {
|
||||
compute() {
|
||||
return (this.messaging.device.isSmall && this.activeMobileNavbarTabId !== 'mailbox') ? {} : clear();
|
||||
},
|
||||
inverse: 'discussOwner',
|
||||
}),
|
||||
/**
|
||||
* The navbar view on the discuss app when in mobile and when not
|
||||
* replying to a message from inbox.
|
||||
*/
|
||||
mobileMessagingNavbarView: one('MobileMessagingNavbarView', {
|
||||
compute() {
|
||||
if (
|
||||
this.messaging.device &&
|
||||
this.messaging.device.isSmall &&
|
||||
!(this.threadView && this.threadView.replyingToMessageView)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'discuss',
|
||||
}),
|
||||
/**
|
||||
* Quick search input value in the discuss sidebar (desktop). Useful
|
||||
* to filter channels and chats based on this input content.
|
||||
*/
|
||||
sidebarQuickSearchValue: attr({
|
||||
default: "",
|
||||
}),
|
||||
thread: one('Thread'),
|
||||
/**
|
||||
* States the `ThreadView` displaying `this.thread`.
|
||||
*/
|
||||
threadView: one('ThreadView', {
|
||||
related: 'threadViewer.threadView',
|
||||
}),
|
||||
/**
|
||||
* Determines the `ThreadViewer` managing the display of `this.thread`.
|
||||
*/
|
||||
threadViewer: one('ThreadViewer', {
|
||||
compute() {
|
||||
return {
|
||||
hasMemberList: true,
|
||||
hasThreadView: this.hasThreadView,
|
||||
hasTopbar: true,
|
||||
thread: this.activeThread ? this.activeThread : clear(),
|
||||
};
|
||||
},
|
||||
inverse: 'discuss',
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'DiscussPublicView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Creates and displays the thread view and clears the welcome view.
|
||||
*/
|
||||
async switchToThreadView() {
|
||||
this.update({
|
||||
threadViewer: {
|
||||
extraClass: 'flex-grow-1',
|
||||
hasMemberList: true,
|
||||
hasThreadView: true,
|
||||
hasTopbar: true,
|
||||
thread: this.channel,
|
||||
},
|
||||
welcomeView: clear(),
|
||||
});
|
||||
if (this.isChannelTokenSecret) {
|
||||
// Change the URL to avoid leaking the invitation link.
|
||||
window.history.replaceState(window.history.state, null, `/discuss/channel/${this.channel.id}${window.location.search}`);
|
||||
}
|
||||
if (this.channel.defaultDisplayMode === 'video_full_screen') {
|
||||
await this.channel.toggleCall({ startWithVideo: true });
|
||||
await this.threadView.callView.activateFullScreen();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Creates and displays the welcome view and clears the thread viewer.
|
||||
*/
|
||||
switchToWelcomeView() {
|
||||
this.update({
|
||||
threadViewer: clear(),
|
||||
welcomeView: {
|
||||
channel: this.channel,
|
||||
isDoFocusGuestNameInput: true,
|
||||
originalGuestName: this.messaging.currentGuest && this.messaging.currentGuest.name,
|
||||
pendingGuestName: this.messaging.currentGuest && this.messaging.currentGuest.name,
|
||||
},
|
||||
});
|
||||
if (this.welcomeView.callDemoView) {
|
||||
this.welcomeView.callDemoView.enableMicrophone();
|
||||
this.welcomeView.callDemoView.enableVideo();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* States the channel linked to this discuss public view.
|
||||
*/
|
||||
channel: one('Thread', {
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
isChannelTokenSecret: attr({
|
||||
default: true,
|
||||
}),
|
||||
messagingAsPublicView: one('Messaging', {
|
||||
compute() {
|
||||
return this.messaging;
|
||||
},
|
||||
inverse: 'discussPublicView',
|
||||
}),
|
||||
shouldAddGuestAsMemberOnJoin: attr({
|
||||
default: false,
|
||||
readonly: true,
|
||||
}),
|
||||
shouldDisplayWelcomeViewInitially: attr({
|
||||
default: false,
|
||||
readonly: true,
|
||||
}),
|
||||
/**
|
||||
* States the thread view linked to this discuss public view.
|
||||
*/
|
||||
threadView: one('ThreadView', {
|
||||
related: 'threadViewer.threadView',
|
||||
}),
|
||||
/**
|
||||
* States the thread viewer linked to this discuss public view.
|
||||
*/
|
||||
threadViewer: one('ThreadViewer', {
|
||||
inverse: 'discussPublicView',
|
||||
}),
|
||||
/**
|
||||
* States the welcome view linked to this discuss public view.
|
||||
*/
|
||||
welcomeView: one('WelcomeView', {
|
||||
inverse: 'discussPublicView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'DiscussSidebarCategory',
|
||||
identifyingMode: 'xor',
|
||||
modelMethods: {
|
||||
/**
|
||||
* Performs the `set_res_users_settings` RPC on `res.users.settings`.
|
||||
*
|
||||
* @param {Object} resUsersSettings
|
||||
* @param {boolean} [resUsersSettings.is_category_channel_open]
|
||||
* @param {boolean} [resUsersSettings.is_category_chat_open]
|
||||
*/
|
||||
async performRpcSetResUsersSettings(resUsersSettings) {
|
||||
return this.messaging.rpc(
|
||||
{
|
||||
model: 'res.users.settings',
|
||||
method: 'set_res_users_settings',
|
||||
args: [[this.messaging.currentUser.res_users_settings_id.id]],
|
||||
kwargs: {
|
||||
new_settings: resUsersSettings,
|
||||
},
|
||||
},
|
||||
{ shadow: true },
|
||||
);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* Closes the category and notity server to change the state
|
||||
*/
|
||||
async close() {
|
||||
this.update({ isPendingOpen: false });
|
||||
await this.messaging.models['DiscussSidebarCategory'].performRpcSetResUsersSettings({
|
||||
[this.serverStateKey]: false,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Opens the category and notity server to change the state
|
||||
*/
|
||||
async open() {
|
||||
this.update({ isPendingOpen: true });
|
||||
await this.messaging.models['DiscussSidebarCategory'].performRpcSetResUsersSettings({
|
||||
[this.serverStateKey]: true,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Changes the category open states when clicked.
|
||||
*/
|
||||
async onClick() {
|
||||
if (this.isOpen) {
|
||||
await this.close();
|
||||
} else {
|
||||
await this.open();
|
||||
}
|
||||
},
|
||||
onHideAddingItem() {
|
||||
this.update({ isAddingItem: false });
|
||||
},
|
||||
/**
|
||||
* @param {Event} ev
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
onAddItemAutocompleteSelect(ev, ui) {
|
||||
switch (this.autocompleteMethod) {
|
||||
case 'channel':
|
||||
this.messaging.discuss.handleAddChannelAutocompleteSelect(ev, ui);
|
||||
break;
|
||||
case 'chat':
|
||||
this.messaging.discuss.handleAddChatAutocompleteSelect(ev, ui);
|
||||
break;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
onAddItemAutocompleteSource(req, res) {
|
||||
switch (this.autocompleteMethod) {
|
||||
case 'channel':
|
||||
this.messaging.discuss.handleAddChannelAutocompleteSource(req, res);
|
||||
break;
|
||||
case 'chat':
|
||||
this.messaging.discuss.handleAddChatAutocompleteSource(req, res);
|
||||
break;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickCommandAdd(ev) {
|
||||
ev.stopPropagation();
|
||||
this.update({ isAddingItem: true });
|
||||
},
|
||||
/**
|
||||
* Redirects to the public channels window when view command is clicked.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickCommandView(ev) {
|
||||
ev.stopPropagation();
|
||||
return this.env.services.action.doAction({
|
||||
name: this.env._t("Public Channels"),
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'mail.channel',
|
||||
views: [[false, 'kanban'], [false, 'form']],
|
||||
domain: [['channel_type', '=', 'channel']],
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Handles change of open state coming from the server. Useful to
|
||||
* clear pending state once server acknowledged the change.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onIsServerOpenChanged() {
|
||||
if (this.isServerOpen === this.isPendingOpen) {
|
||||
this.update({ isPendingOpen: clear() });
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* The category item which is active and belongs
|
||||
* to the category.
|
||||
*/
|
||||
activeItem: one('DiscussSidebarCategoryItem', {
|
||||
compute() {
|
||||
const channel = this.messaging.discuss.activeThread && this.messaging.discuss.activeThread.channel;
|
||||
if (channel && this.supportedChannelTypes.includes(channel.channel_type)) {
|
||||
return {
|
||||
category: this,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
addingItemAutocompleteInputView: one('AutocompleteInputView', {
|
||||
compute() {
|
||||
if (this.isOpen && this.isAddingItem) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'discussSidebarCategoryOwnerAsAddingItem',
|
||||
}),
|
||||
/**
|
||||
* Determines how the autocomplete of this category should behave.
|
||||
* Must be one of: 'channel', 'chat'.
|
||||
*/
|
||||
autocompleteMethod: attr({
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return 'channel';
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return 'chat';
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
/**
|
||||
* Determines the discuss sidebar category items that are displayed by
|
||||
* this discuss sidebar category.
|
||||
*/
|
||||
categoryItems: many('DiscussSidebarCategoryItem', {
|
||||
inverse: 'category',
|
||||
}),
|
||||
categoryItemsOrderedByLastAction: many('DiscussSidebarCategoryItem', {
|
||||
compute() {
|
||||
if (this.discussAsChat) {
|
||||
return this.categoryItems;
|
||||
}
|
||||
// clear if the value is not going to be used, so that it avoids
|
||||
// sorting the items for nothing
|
||||
return clear();
|
||||
},
|
||||
sort: [
|
||||
['truthy-first', 'thread'],
|
||||
['truthy-first', 'thread.lastInterestDateTime'],
|
||||
['most-recent-first', 'thread.lastInterestDateTime'],
|
||||
['greater-first', 'channel.id'],
|
||||
],
|
||||
}),
|
||||
categoryItemsOrderedByName: many('DiscussSidebarCategoryItem', {
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return this.categoryItems;
|
||||
}
|
||||
// clear if the value is not going to be used, so that it avoids
|
||||
// sorting the items for nothing
|
||||
return clear();
|
||||
},
|
||||
sort: [
|
||||
['truthy-first', 'thread'],
|
||||
['truthy-first', 'thread.displayName'],
|
||||
['case-insensitive-asc', 'thread.displayName'],
|
||||
['smaller-first', 'channel.id'],
|
||||
],
|
||||
}),
|
||||
/**
|
||||
* The title text in UI for command `add`
|
||||
*/
|
||||
commandAddTitleText: attr({
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return this.env._t("Add or join a channel");
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return this.env._t("Start a conversation");
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
/**
|
||||
* States the total amount of unread/action-needed threads in this
|
||||
* category.
|
||||
*/
|
||||
counter: attr({
|
||||
default: 0,
|
||||
readonly: true,
|
||||
sum: 'categoryItems.categoryCounterContribution',
|
||||
}),
|
||||
discussAsChannel: one('Discuss', {
|
||||
identifying: true,
|
||||
inverse: 'categoryChannel',
|
||||
}),
|
||||
discussAsChat: one('Discuss', {
|
||||
identifying: true,
|
||||
inverse: 'categoryChat',
|
||||
}),
|
||||
/**
|
||||
* Determines the filtered and sorted discuss sidebar category items
|
||||
* that are displayed by this discuss sidebar category.
|
||||
*/
|
||||
filteredCategoryItems: many('DiscussSidebarCategoryItem', {
|
||||
compute() {
|
||||
let categoryItems = this.orderedCategoryItems;
|
||||
const searchValue = this.messaging.discuss.sidebarQuickSearchValue;
|
||||
if (searchValue) {
|
||||
const qsVal = searchValue.toLowerCase();
|
||||
categoryItems = categoryItems.filter(categoryItem => {
|
||||
const nameVal = categoryItem.channel.displayName.toLowerCase();
|
||||
return nameVal.includes(qsVal);
|
||||
});
|
||||
}
|
||||
return categoryItems;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Display name of the category.
|
||||
*/
|
||||
name: attr({
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return this.env._t("Channels");
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return this.env._t("Direct Messages");
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
/**
|
||||
* Boolean that determines whether this category has a 'add' command.
|
||||
*/
|
||||
hasAddCommand: attr({
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return true;
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Boolean that determines whether this category has a 'view' command.
|
||||
*/
|
||||
hasViewCommand: attr({
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Boolean that determines whether discuss is adding a new category item.
|
||||
*/
|
||||
isAddingItem: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Boolean that determines whether this category is open.
|
||||
*/
|
||||
isOpen: attr({
|
||||
compute() {
|
||||
return this.isPendingOpen !== undefined ? this.isPendingOpen : this.isServerOpen;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Boolean that determines if there is a pending open state change,
|
||||
* which is requested by the client but not yet confirmed by the server.
|
||||
*
|
||||
* This field can be updated to immediately change the open state on the
|
||||
* interface and to notify the server of the new state.
|
||||
*/
|
||||
isPendingOpen: attr(),
|
||||
/**
|
||||
* Boolean that determines the last open state known by the server.
|
||||
*/
|
||||
isServerOpen: attr({
|
||||
compute() {
|
||||
// there is no server state for non-users (guests)
|
||||
if (!this.messaging.currentUser) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.messaging.currentUser.res_users_settings_id) {
|
||||
return clear();
|
||||
}
|
||||
if (this.discussAsChannel) {
|
||||
return this.messaging.currentUser.res_users_settings_id.is_discuss_sidebar_category_channel_open;
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return this.messaging.currentUser.res_users_settings_id.is_discuss_sidebar_category_chat_open;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* The placeholder text used when a new item is being added in UI.
|
||||
*/
|
||||
newItemPlaceholderText: attr({
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return this.env._t("Find or create a channel...");
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return this.env._t("Find or start a conversation...");
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
orderedCategoryItems: many('DiscussSidebarCategoryItem', {
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return this.categoryItemsOrderedByName;
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return this.categoryItemsOrderedByLastAction;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* The key used in the server side for the category state
|
||||
*/
|
||||
serverStateKey: attr({
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return 'is_discuss_sidebar_category_channel_open';
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return 'is_discuss_sidebar_category_chat_open';
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Channel type which is supported by the category.
|
||||
*/
|
||||
supportedChannelTypes: attr({
|
||||
compute() {
|
||||
if (this.discussAsChannel) {
|
||||
return ['channel'];
|
||||
}
|
||||
if (this.discussAsChat) {
|
||||
return ['chat', 'group'];
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['isServerOpen'],
|
||||
methodName: '_onIsServerOpenChanged',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import Dialog from 'web.Dialog';
|
||||
|
||||
registerModel({
|
||||
name: 'DiscussSidebarCategoryItem',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
this.thread.open();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickCommandLeave(ev) {
|
||||
ev.stopPropagation();
|
||||
if (this.channel.channel_type !== 'group' && this.thread.creator === this.messaging.currentUser) {
|
||||
await this._askAdminConfirmation();
|
||||
}
|
||||
if (this.channel.channel_type === 'group') {
|
||||
await this._askLeaveGroupConfirmation();
|
||||
}
|
||||
this.thread.leave();
|
||||
},
|
||||
/**
|
||||
* Redirects to channel form page when `settings` command is clicked.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickCommandSettings(ev) {
|
||||
ev.stopPropagation();
|
||||
return this.env.services.action.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: this.thread.model,
|
||||
res_id: this.thread.id,
|
||||
views: [[false, 'form']],
|
||||
target: 'current',
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickCommandUnpin(ev) {
|
||||
ev.stopPropagation();
|
||||
this.thread.unsubscribe();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_askAdminConfirmation() {
|
||||
return new Promise(resolve => {
|
||||
Dialog.confirm(this,
|
||||
this.env._t("You are the administrator of this channel. Are you sure you want to leave?"),
|
||||
{
|
||||
buttons: [
|
||||
{
|
||||
text: this.env._t("Leave"),
|
||||
classes: 'btn-primary',
|
||||
close: true,
|
||||
click: resolve,
|
||||
},
|
||||
{
|
||||
text: this.env._t("Discard"),
|
||||
close: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_askLeaveGroupConfirmation() {
|
||||
return new Promise(resolve => {
|
||||
Dialog.confirm(this,
|
||||
this.env._t("You are about to leave this group conversation and will no longer have access to it unless you are invited again. Are you sure you want to continue?"),
|
||||
{
|
||||
buttons: [
|
||||
{
|
||||
text: this.env._t("Leave"),
|
||||
classes: 'btn-primary',
|
||||
close: true,
|
||||
click: resolve
|
||||
},
|
||||
{
|
||||
text: this.env._t("Discard"),
|
||||
close: true
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Image URL for the related channel thread.
|
||||
*/
|
||||
avatarUrl: attr({
|
||||
compute() {
|
||||
switch (this.channel.channel_type) {
|
||||
case 'channel':
|
||||
case 'group':
|
||||
return `/web/image/mail.channel/${this.channel.id}/avatar_128?unique=${this.channel.avatarCacheKey}`;
|
||||
case 'chat':
|
||||
if (this.channel.correspondent) {
|
||||
return this.channel.correspondent.avatarUrl;
|
||||
}
|
||||
}
|
||||
return '/mail/static/src/img/smiley/avatar.jpg';
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Determines the discuss sidebar category displaying this item.
|
||||
*/
|
||||
category: one('DiscussSidebarCategory', {
|
||||
identifying: true,
|
||||
inverse: 'categoryItems',
|
||||
}),
|
||||
/**
|
||||
* Determines the contribution of this discuss sidebar category item to
|
||||
* the counter of this category.
|
||||
*/
|
||||
categoryCounterContribution: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
switch (this.channel.channel_type) {
|
||||
case 'channel':
|
||||
return this.thread.message_needaction_counter > 0 ? 1 : 0;
|
||||
case 'chat':
|
||||
case 'group':
|
||||
return this.channel.localMessageUnreadCounter > 0 ? 1 : 0;
|
||||
}
|
||||
},
|
||||
}),
|
||||
channel: one('Channel', {
|
||||
identifying: true,
|
||||
inverse: 'discussSidebarCategoryItem',
|
||||
}),
|
||||
/**
|
||||
* Amount of unread/action-needed messages
|
||||
*/
|
||||
counter: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
switch (this.channel.channel_type) {
|
||||
case 'channel':
|
||||
return this.thread.message_needaction_counter;
|
||||
case 'chat':
|
||||
case 'group':
|
||||
return this.channel.localMessageUnreadCounter;
|
||||
}
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Boolean determines whether the item has a "leave" command
|
||||
*/
|
||||
hasLeaveCommand: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
return (
|
||||
['channel', 'group'].includes(this.channel.channel_type) &&
|
||||
!this.thread.message_needaction_counter &&
|
||||
!this.thread.group_based_subscription
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Boolean determines whether the item has a "settings" command.
|
||||
*/
|
||||
hasSettingsCommand: attr({
|
||||
compute() {
|
||||
return this.channel.channel_type === 'channel';
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Boolean determines whether ThreadIcon will be displayed in UI.
|
||||
*/
|
||||
hasThreadIcon: attr({
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return clear();
|
||||
}
|
||||
switch (this.channel.channel_type) {
|
||||
case 'channel':
|
||||
return !Boolean(this.thread.authorizedGroupFullName);
|
||||
case 'chat':
|
||||
return true;
|
||||
case 'group':
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Boolean determines whether the item has a "unpin" command.
|
||||
*/
|
||||
hasUnpinCommand: attr({
|
||||
compute() {
|
||||
return this.channel.channel_type === 'chat' && !this.channel.localMessageUnreadCounter;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Boolean determines whether the item is currently active in discuss.
|
||||
*/
|
||||
isActive: attr({
|
||||
compute() {
|
||||
return this.messaging.discuss && this.thread === this.messaging.discuss.activeThread;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Boolean determines whether the item has any unread messages.
|
||||
*/
|
||||
isUnread: attr({
|
||||
compute() {
|
||||
return this.channel.localMessageUnreadCounter > 0;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* The related thread.
|
||||
*/
|
||||
thread: one('Thread', {
|
||||
related: 'channel.thread'
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'DiscussSidebarMailboxView',
|
||||
identifyingMode: 'xor',
|
||||
fields: {
|
||||
discussViewOwnerAsHistory: one('DiscussView', {
|
||||
identifying: true,
|
||||
inverse: 'historyView',
|
||||
}),
|
||||
discussViewOwnerAsInbox: one('DiscussView', {
|
||||
identifying: true,
|
||||
inverse: 'inboxView',
|
||||
}),
|
||||
discussViewOwnerAsStarred: one('DiscussView', {
|
||||
identifying: true,
|
||||
inverse: 'starredView',
|
||||
}),
|
||||
mailbox: one('Mailbox', {
|
||||
compute() {
|
||||
if (this.discussViewOwnerAsHistory) {
|
||||
return this.messaging.history;
|
||||
}
|
||||
if (this.discussViewOwnerAsInbox) {
|
||||
return this.messaging.inbox;
|
||||
}
|
||||
if (this.discussViewOwnerAsStarred) {
|
||||
return this.messaging.starred;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'DiscussView',
|
||||
recordMethods: {
|
||||
clearIsAddingItem() {
|
||||
this.update({
|
||||
addingChannelValue: clear(),
|
||||
isAddingChannel: clear(),
|
||||
isAddingChat: clear(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Handles click on the mobile "new channel" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickMobileNewChannelButton(ev) {
|
||||
this.update({ isAddingChannel: true });
|
||||
},
|
||||
/**
|
||||
* Handles click on the mobile "new chat" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickMobileNewChatButton(ev) {
|
||||
this.update({ isAddingChat: true });
|
||||
},
|
||||
/**
|
||||
* Handles click on the "Start a meeting" button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickStartAMeetingButton(ev) {
|
||||
const meetingChannel = await this.messaging.models['Thread'].createGroupChat({
|
||||
default_display_mode: 'video_full_screen',
|
||||
partners_to: [this.messaging.currentPartner.id],
|
||||
});
|
||||
meetingChannel.toggleCall({ startWithVideo: true });
|
||||
await meetingChannel.open({ focus: false });
|
||||
if (!meetingChannel.exists() || !this.discuss.threadView) {
|
||||
return;
|
||||
}
|
||||
this.discuss.threadView.topbar.openInvitePopoverView();
|
||||
},
|
||||
onHideMobileAddItemHeader() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.clearIsAddingItem();
|
||||
},
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onInputQuickSearch(ev) {
|
||||
ev.stopPropagation();
|
||||
this.discuss.onInputQuickSearch(this.quickSearchInputRef.el.value);
|
||||
},
|
||||
/**
|
||||
* Called when clicking on a mailbox selection item.
|
||||
*
|
||||
* @param {Mailbox} mailbox
|
||||
*/
|
||||
onClickMobileMailboxSelectionItem(mailbox) {
|
||||
if (!mailbox.exists()) {
|
||||
return;
|
||||
}
|
||||
mailbox.thread.open();
|
||||
},
|
||||
/**
|
||||
* @param {Event} ev
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
onMobileAddItemHeaderInputSelect(ev, ui) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.isAddingChannel) {
|
||||
this.discuss.handleAddChannelAutocompleteSelect(ev, ui);
|
||||
} else {
|
||||
this.discuss.handleAddChatAutocompleteSelect(ev, ui);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
onMobileAddItemHeaderInputSource(req, res) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.isAddingChannel) {
|
||||
this.discuss.handleAddChannelAutocompleteSource(req, res);
|
||||
} else {
|
||||
this.discuss.handleAddChatAutocompleteSource(req, res);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onDiscussActiveThreadChanged() {
|
||||
this.env.services.router.pushState({
|
||||
action: this.discuss.discussView.actionId,
|
||||
active_id: this.discuss.activeId,
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Used to push state when changing active thread.
|
||||
* The id of the action which opened discuss.
|
||||
*/
|
||||
actionId: attr(),
|
||||
/**
|
||||
* Value that is used to create a channel from the sidebar.
|
||||
*/
|
||||
addingChannelValue: attr({
|
||||
default: "",
|
||||
}),
|
||||
discuss: one('Discuss', {
|
||||
identifying: true,
|
||||
inverse: 'discussView',
|
||||
}),
|
||||
historyView: one('DiscussSidebarMailboxView', {
|
||||
default: {},
|
||||
inverse: 'discussViewOwnerAsHistory',
|
||||
}),
|
||||
inboxView: one('DiscussSidebarMailboxView', {
|
||||
default: {},
|
||||
inverse: 'discussViewOwnerAsInbox',
|
||||
}),
|
||||
/**
|
||||
* Determines whether current user is adding a channel from the sidebar.
|
||||
*/
|
||||
isAddingChannel: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determines whether current user is adding a chat from the sidebar.
|
||||
*/
|
||||
isAddingChat: attr({
|
||||
default: false,
|
||||
}),
|
||||
mobileAddItemHeaderAutocompleteInputView: one('AutocompleteInputView', {
|
||||
compute() {
|
||||
if (
|
||||
this.messaging.device.isSmall &&
|
||||
(this.isAddingChannel || this.isAddingChat)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'discussViewOwnerAsMobileAddItemHeader',
|
||||
}),
|
||||
orderedMailboxes: many('Mailbox', {
|
||||
related: 'messaging.allMailboxes',
|
||||
sort: [['smaller-first', 'sequence']],
|
||||
}),
|
||||
/**
|
||||
* Reference of the quick search input. Useful to filter channels and
|
||||
* chats based on this input content.
|
||||
*/
|
||||
quickSearchInputRef: attr(),
|
||||
starredView: one('DiscussSidebarMailboxView', {
|
||||
default: {},
|
||||
inverse: 'discussViewOwnerAsStarred',
|
||||
}),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['discuss.activeThread'],
|
||||
methodName: '_onDiscussActiveThreadChanged',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { decrement, increment } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'DropZoneView',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Shows a visual drop effect when dragging inside the dropzone.
|
||||
*
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
onDragenter(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
if (this.dragCount === 0) {
|
||||
this.update({ isDraggingInside: true });
|
||||
}
|
||||
this.update({ dragCount: increment() });
|
||||
},
|
||||
/**
|
||||
* Hides the visual drop effect when dragging outside the dropzone.
|
||||
*
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
onDragleave(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ dragCount: decrement() });
|
||||
if (this.dragCount === 0) {
|
||||
this.update({ isDraggingInside: false });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Prevents default (from the template) in order to receive the drop event.
|
||||
* The drop effect cursor works only when set on dragover.
|
||||
*
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
onDragover(ev) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
},
|
||||
/**
|
||||
* Trigger callback 'props.onDropzoneFilesDropped' with event when new files are dropped
|
||||
* on the dropzone, and then removes the visual drop effect.
|
||||
*
|
||||
* The parents should handle this event to process the files as they wish,
|
||||
* such as uploading them.
|
||||
*
|
||||
* @param {DragEvent} ev
|
||||
*/
|
||||
async onDrop(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this.update({ isDraggingInside: false });
|
||||
if (this._isDragSourceExternalFile(ev.dataTransfer)) {
|
||||
const files = ev.dataTransfer.files;
|
||||
if (this.chatterOwner) {
|
||||
const chatter = this.chatterOwner;
|
||||
if (chatter.isTemporary) {
|
||||
const saved = await chatter.doSaveRecord();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await chatter.fileUploader.uploadFiles(files);
|
||||
return;
|
||||
}
|
||||
if (this.composerViewOwner) {
|
||||
await this.composerViewOwner.fileUploader.uploadFiles(files);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Making sure that dragging content is external files.
|
||||
* Ignoring other content dragging like text.
|
||||
*
|
||||
* @private
|
||||
* @param {DataTransfer} dataTransfer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isDragSourceExternalFile(dataTransfer) {
|
||||
const dragDataType = dataTransfer.types;
|
||||
if (dragDataType.constructor === window.DOMStringList) {
|
||||
return dragDataType.contains('Files');
|
||||
}
|
||||
if (dragDataType.constructor === Array) {
|
||||
return dragDataType.includes('Files');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
chatterOwner: one('Chatter', {
|
||||
identifying: true,
|
||||
inverse: 'dropZoneView',
|
||||
}),
|
||||
composerViewOwner: one('ComposerView', {
|
||||
identifying: true,
|
||||
inverse: 'dropZoneView',
|
||||
}),
|
||||
/**
|
||||
* Counts how many drag enter/leave happened on self and children. This
|
||||
* ensures the drop effect stays active when dragging over a child.
|
||||
*/
|
||||
dragCount: attr({
|
||||
default: 0,
|
||||
}),
|
||||
/**
|
||||
* Determines whether the user is dragging files over the dropzone.
|
||||
* Useful to provide visual feedback in that case.
|
||||
*/
|
||||
isDraggingInside: attr({
|
||||
default: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
97
odoo-bringout-oca-ocb-mail/mail/static/src/models/emoji.js
Normal file
97
odoo-bringout-oca-ocb-mail/mail/static/src/models/emoji.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'Emoji',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Compares two strings
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_fuzzySearch(string, search) {
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < string.length) {
|
||||
if (string[i] === search[j]) {
|
||||
j += 1;
|
||||
}
|
||||
if (j === search.length) {
|
||||
return true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isStringInEmojiKeywords(string) {
|
||||
for (let index in this.searchData) {
|
||||
if (this._fuzzySearch(this.searchData[index], string)) { //If at least one correspondence is found, return true.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
allEmojiInCategoryOfCurrent: many('EmojiInCategory', {
|
||||
compute() {
|
||||
return this.emojiCategories.map(category => ({ category }));
|
||||
},
|
||||
inverse: 'emoji',
|
||||
}),
|
||||
codepoints: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
emojiCategories: many('EmojiCategory', {
|
||||
compute() {
|
||||
if (!this.emojiRegistry) {
|
||||
return clear();
|
||||
}
|
||||
return [this.emojiDataCategory];
|
||||
},
|
||||
inverse: 'allEmojis',
|
||||
}),
|
||||
emojiDataCategory: one('EmojiCategory'),
|
||||
emojiOrEmojiInCategory: many('EmojiOrEmojiInCategory', {
|
||||
inverse: 'emoji',
|
||||
}),
|
||||
emojiRegistry: one('EmojiRegistry', {
|
||||
compute() {
|
||||
if (!this.messaging) {
|
||||
return clear();
|
||||
}
|
||||
return this.messaging.emojiRegistry;
|
||||
},
|
||||
inverse: 'allEmojis',
|
||||
required: true,
|
||||
}),
|
||||
emojiViews: many('EmojiView', {
|
||||
inverse: 'emoji',
|
||||
readonly: true,
|
||||
}),
|
||||
emoticons: attr(),
|
||||
keywords: attr(),
|
||||
name: attr({
|
||||
readonly: true,
|
||||
}),
|
||||
searchData: attr({
|
||||
compute() {
|
||||
return [...this.shortcodes, ...this.emoticons, ...this.name, ...this.keywords];
|
||||
},
|
||||
}),
|
||||
shortcodes: attr(),
|
||||
sources: attr({
|
||||
compute() {
|
||||
return [...this.shortcodes, ...this.emoticons];
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiCategory',
|
||||
fields: {
|
||||
allEmojiInCategoryOfCurrent: many('EmojiInCategory', {
|
||||
inverse: 'category',
|
||||
}),
|
||||
allEmojiPickerViewCategory: many('EmojiPickerView.Category', {
|
||||
inverse: 'category',
|
||||
}),
|
||||
allEmojis: many('Emoji', {
|
||||
inverse: 'emojiCategories',
|
||||
}),
|
||||
displayName: attr(),
|
||||
emojiCount: attr({ //Number of emojis that will be in that category once every emoji is loaded.
|
||||
default: 0,
|
||||
}),
|
||||
emojiRegistry: one("EmojiRegistry", {
|
||||
compute() {
|
||||
return this.messaging.emojiRegistry;
|
||||
},
|
||||
inverse: "allCategories",
|
||||
required: true,
|
||||
}),
|
||||
name: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
sortId: attr({
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
title: attr({
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiCategoryBarView',
|
||||
fields: {
|
||||
emojiCategoryViews: many('EmojiCategoryView', {
|
||||
compute() {
|
||||
if (!this.emojiPickerView) {
|
||||
return clear();
|
||||
}
|
||||
return this.emojiPickerView.categories.map(category => ({ viewCategory: category }));
|
||||
},
|
||||
inverse: 'emojiCategoryBarViewOwner',
|
||||
}),
|
||||
emojiPickerHeaderViewOwner: one('EmojiPickerHeaderView', {
|
||||
identifying: true,
|
||||
inverse: 'emojiCategoryBarView',
|
||||
}),
|
||||
emojiPickerView: one('EmojiPickerView', {
|
||||
related: 'emojiPickerHeaderViewOwner.emojiPickerViewOwner',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiCategoryView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick() {
|
||||
this.emojiPickerView.emojiSearchBarView.reset();
|
||||
this.emojiPickerView.emojiGridView.update({ categorySelectedByUser: this.viewCategory });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseenter(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ isHovered: true });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseleave(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ isHovered: false });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
category: one('EmojiCategory', {
|
||||
related: 'viewCategory.category',
|
||||
}),
|
||||
emojiCategoryBarViewOwner: one('EmojiCategoryBarView', {
|
||||
identifying: true,
|
||||
inverse: 'emojiCategoryViews',
|
||||
}),
|
||||
emojiPickerView: one('EmojiPickerView', {
|
||||
related: 'emojiCategoryBarViewOwner.emojiPickerView',
|
||||
}),
|
||||
isActive: attr({
|
||||
compute() {
|
||||
return Boolean(this.viewCategory.emojiPickerViewAsActive);
|
||||
},
|
||||
}),
|
||||
isHovered: attr({
|
||||
default: false,
|
||||
}),
|
||||
viewCategory: one('EmojiPickerView.Category', {
|
||||
identifying: true,
|
||||
inverse: 'emojiCategoryView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiGridItemView',
|
||||
fields: {
|
||||
emojiOrEmojiInCategory: one('EmojiOrEmojiInCategory', {
|
||||
identifying: true,
|
||||
inverse: 'emojiGridItemViews',
|
||||
}),
|
||||
emojiGridRowViewOwner: one('EmojiGridRowView', {
|
||||
identifying: true,
|
||||
inverse: 'items',
|
||||
}),
|
||||
emojiView: one('EmojiView', {
|
||||
compute() {
|
||||
if (this.emojiOrEmojiInCategory.emoji) {
|
||||
return { emoji: this.emojiOrEmojiInCategory.emoji };
|
||||
}
|
||||
if (this.emojiOrEmojiInCategory.emojiInCategory) {
|
||||
return { emoji: this.emojiOrEmojiInCategory.emojiInCategory.emoji };
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'emojiGridItemViewOwner',
|
||||
}),
|
||||
width: attr({
|
||||
compute() {
|
||||
if (!this.emojiGridRowViewOwner.emojiGridViewOwner) {
|
||||
return clear();
|
||||
}
|
||||
return this.emojiGridRowViewOwner.emojiGridViewOwner.itemWidth;
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiGridLoadingScreen',
|
||||
fields: {
|
||||
emojiGridViewOwner: one('EmojiGridView', {
|
||||
identifying: true,
|
||||
inverse: 'loadingScreenView',
|
||||
}),
|
||||
text: attr({
|
||||
compute() {
|
||||
return this.env._t("Loading...");
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiGridNoSearchContentView',
|
||||
fields: {
|
||||
emojiGridViewOwner: one('EmojiGridView', {
|
||||
identifying: true,
|
||||
inverse: 'searchNoContentView',
|
||||
}),
|
||||
text: attr({
|
||||
compute() {
|
||||
return this.env._t("No emoji match your search");
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiGridRowView',
|
||||
fields: {
|
||||
emojiGridViewOwner: one('EmojiGridView', {
|
||||
related: 'emojiGridViewRowRegistryOwner.emojiGridViewOwner',
|
||||
}),
|
||||
index: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
items: many('EmojiGridItemView', {
|
||||
inverse: 'emojiGridRowViewOwner',
|
||||
}),
|
||||
sectionView: one('EmojiGridSectionView', {
|
||||
compute() {
|
||||
if (this.viewCategory) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'emojiGridRowViewOwner',
|
||||
}),
|
||||
emojiGridViewRowRegistryOwner: one('EmojiGridViewRowRegistry', {
|
||||
identifying: true,
|
||||
inverse: 'rows',
|
||||
}),
|
||||
viewCategory: one('EmojiPickerView.Category', {
|
||||
inverse: 'emojiGridRowView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiGridSectionView',
|
||||
fields: {
|
||||
category: one('EmojiCategory', {
|
||||
related: 'viewCategory.category',
|
||||
}),
|
||||
emojiGridRowViewOwner: one('EmojiGridRowView', {
|
||||
identifying: true,
|
||||
inverse: 'sectionView',
|
||||
}),
|
||||
viewCategory: one('EmojiPickerView.Category', {
|
||||
related: 'emojiGridRowViewOwner.viewCategory',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear, increment } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiGridView',
|
||||
recordMethods: {
|
||||
doJumpToCategorySelectedByUser() {
|
||||
this.containerRef.el.scrollTo({
|
||||
top: this.rowHeight * this.categorySelectedByUser.emojiGridRowView.index,
|
||||
});
|
||||
this.update({ categorySelectedByUser: clear() });
|
||||
},
|
||||
/**
|
||||
* Handles OWL update on component update.
|
||||
*/
|
||||
onComponentUpdate() {
|
||||
if (
|
||||
this.categorySelectedByUser &&
|
||||
this.emojiPickerViewOwner.emojiSearchBarView.currentSearch === ""
|
||||
) {
|
||||
this.doJumpToCategorySelectedByUser();
|
||||
}
|
||||
},
|
||||
onScroll() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.onScrollThrottle.do();
|
||||
},
|
||||
_onChangeScrollRecomputeCount() {
|
||||
for (const viewCategory of this.emojiPickerViewOwner.categories) {
|
||||
if (
|
||||
viewCategory.emojiGridRowView &&
|
||||
this.scrollIndex >= viewCategory.emojiGridRowView.index &&
|
||||
(viewCategory.emojiPickerViewOwnerAsLastCategory || this.scrollIndex <= viewCategory.endSectionIndex)
|
||||
) {
|
||||
this.emojiPickerViewOwner.update({ activeCategoryByGridViewScroll: viewCategory });
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
* Filters emoji according to the current search terms.
|
||||
*/
|
||||
_filterEmoji(emoji) {
|
||||
return (emoji._isStringInEmojiKeywords(this.emojiPickerViewOwner.emojiSearchBarView.currentSearch));
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
amountOfItemsPerRow: attr({
|
||||
default: 9,
|
||||
}),
|
||||
categorySelectedByUser: one('EmojiPickerView.Category'),
|
||||
containerRef: attr(),
|
||||
/**
|
||||
* Distance of the rendered rows from top.
|
||||
* This is from the PoV of 1st rendered row, including extra rendered rows!
|
||||
*/
|
||||
distanceFromTop: attr({
|
||||
compute() {
|
||||
this.scrollRecomputeCount; // observe scroll changes
|
||||
if (!this.listRef || !this.listRef.el) {
|
||||
return clear();
|
||||
}
|
||||
return Math.max(
|
||||
(this.scrollPercentage * this.listRef.el.clientHeight - this.extraRenderRowsAmount * this.rowHeight),
|
||||
0,
|
||||
);
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
distanceInRowOffset: attr({
|
||||
compute() {
|
||||
return this.distanceFromTop % this.rowHeight;
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
emojiPickerViewOwner: one('EmojiPickerView', {
|
||||
identifying: true,
|
||||
inverse: 'emojiGridView',
|
||||
}),
|
||||
/**
|
||||
* Extra rows above and below the visible part.
|
||||
* 10 means 10 rows above and 10 rows below.
|
||||
*/
|
||||
extraRenderRowsAmount: attr({
|
||||
default: 10,
|
||||
}),
|
||||
firstRenderedRowIndex: attr({
|
||||
compute() {
|
||||
this.scrollRecomputeCount; // observe scroll changes
|
||||
return Math.max(
|
||||
this.scrollIndex - this.extraRenderRowsAmount,
|
||||
0,
|
||||
);
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
height: attr({
|
||||
compute() {
|
||||
return this.rowHeight * 9.5;
|
||||
},
|
||||
}),
|
||||
hoveredEmojiView: one('EmojiView', {
|
||||
inverse: 'emojiGridViewAsHovered',
|
||||
}),
|
||||
itemWidth: attr({
|
||||
default: 30,
|
||||
}),
|
||||
lastRenderedRowIndex: attr({
|
||||
compute() {
|
||||
this.scrollRecomputeCount; // observe scroll changes
|
||||
let value;
|
||||
if (this.firstRenderedRowIndex + this.renderedMaxAmount >= this.rows.length) {
|
||||
value = Math.max(this.rows.length - 1, 0);
|
||||
} else {
|
||||
value = this.firstRenderedRowIndex + this.renderedMaxAmount;
|
||||
}
|
||||
return Math.ceil(value);
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
listHeight: attr({
|
||||
compute() {
|
||||
return this.rowHeight * this.rows.length;
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
listRef: attr(),
|
||||
loadingScreenView: one('EmojiGridLoadingScreen', {
|
||||
compute() {
|
||||
if (!this.messaging.emojiRegistry.isLoaded) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'emojiGridViewOwner',
|
||||
}),
|
||||
nonSearchRowRegistry: one('EmojiGridViewRowRegistry', {
|
||||
default: {},
|
||||
inverse: 'emojiGridViewOwnerAsNonSearch',
|
||||
}),
|
||||
onScrollThrottle: one('Throttle', {
|
||||
compute() {
|
||||
return { func: () => this.update({ scrollRecomputeCount: increment() }) };
|
||||
},
|
||||
inverse: 'emojiGridViewAsOnScroll',
|
||||
}),
|
||||
renderedMaxAmount: attr({
|
||||
compute() {
|
||||
return this.extraRenderRowsAmount * 2 + Math.ceil(this.visibleMaxAmount);
|
||||
},
|
||||
}),
|
||||
renderedRows: many('EmojiGridRowView', {
|
||||
compute() {
|
||||
if (this.lastRenderedRowIndex + 1 - this.firstRenderedRowIndex < 0) {
|
||||
return clear();
|
||||
}
|
||||
if (this.rows.length === 0) {
|
||||
return clear();
|
||||
}
|
||||
return (
|
||||
[...Array(this.lastRenderedRowIndex + 1 - this.firstRenderedRowIndex).keys()]
|
||||
.map(relativeRowIndex => this.rows[this.firstRenderedRowIndex + relativeRowIndex])
|
||||
.filter(row => row !== undefined) // some corner cases where very briefly it doesn't sync with rows and it's bigger
|
||||
);
|
||||
},
|
||||
sort: [['smaller-first', 'index']],
|
||||
}),
|
||||
rowHeight: attr({
|
||||
default: 30,
|
||||
}),
|
||||
rows: many('EmojiGridRowView', {
|
||||
compute() {
|
||||
if (this.emojiPickerViewOwner.emojiSearchBarView.currentSearch !== "") {
|
||||
return this.searchRowRegistry.rows;
|
||||
}
|
||||
return this.nonSearchRowRegistry.rows;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Scroll index of the 1st visible rendered rows (so excluding the extra rendered rendered rows).
|
||||
*/
|
||||
scrollIndex: attr({
|
||||
compute() {
|
||||
this.scrollRecomputeCount; // observe scroll changes
|
||||
return Math.floor(this.scrollPercentage * this.rows.length);
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
/**
|
||||
* Scroll percentage of the 1st visible rendered rows.
|
||||
*/
|
||||
scrollPercentage: attr({
|
||||
compute() {
|
||||
this.scrollRecomputeCount; // observe scroll changes
|
||||
if (!this.containerRef || !this.containerRef.el) {
|
||||
return clear();
|
||||
}
|
||||
return this.containerRef.el.scrollTop / this.containerRef.el.scrollHeight;
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
scrollbarThresholdWidth: attr({
|
||||
default: 15,
|
||||
}),
|
||||
scrollRecomputeCount: attr({
|
||||
default: 0,
|
||||
}),
|
||||
searchNoContentView: one('EmojiGridNoSearchContentView', {
|
||||
compute() {
|
||||
if (this.emojiPickerViewOwner.emojiSearchBarView.currentSearch !== "" && this.rows.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'emojiGridViewOwner',
|
||||
}),
|
||||
searchRowRegistry: one('EmojiGridViewRowRegistry', {
|
||||
default: {},
|
||||
inverse: 'emojiGridViewOwnerAsSearch',
|
||||
}),
|
||||
viewBlockRef: attr(),
|
||||
/**
|
||||
* Amount of emoji that are visibly rendered in emoji grid.
|
||||
* Decimal determines the partial visibility of the last emoji.
|
||||
* For example, 9.5 means 9 emojis fully visible, and the last is half visible.
|
||||
*/
|
||||
visibleMaxAmount: attr({
|
||||
default: 9.5,
|
||||
}),
|
||||
width: attr({
|
||||
compute() {
|
||||
return this.itemWidth * this.amountOfItemsPerRow + this.scrollbarThresholdWidth;
|
||||
},
|
||||
}),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['scrollRecomputeCount'],
|
||||
methodName: '_onChangeScrollRecomputeCount',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiGridViewRowRegistry',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
computeNonSearchRows() {
|
||||
const value = [];
|
||||
let index = 0;
|
||||
for (let viewCategory of this.emojiGridViewOwner.emojiPickerViewOwner.categories) {
|
||||
value.push({ viewCategory, index });
|
||||
index++;
|
||||
let currentItems = [];
|
||||
for (let emojiInCategory of viewCategory.category.allEmojiInCategoryOfCurrent) {
|
||||
currentItems.push({ emojiOrEmojiInCategory: { emojiInCategory } });
|
||||
if (currentItems.length === this.emojiGridViewOwner.amountOfItemsPerRow) {
|
||||
value.push({ items: currentItems, index });
|
||||
currentItems = [];
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (currentItems.length > 0) {
|
||||
value.push({ items: currentItems, index });
|
||||
currentItems = [];
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
computeSearchRows() {
|
||||
if (this.emojiGridViewOwner.emojiPickerViewOwner.emojiSearchBarView.currentSearch === "") {
|
||||
return clear();
|
||||
}
|
||||
const emojis = this.messaging.emojiRegistry.allEmojis.filter(this.emojiGridViewOwner._filterEmoji);
|
||||
const value = [];
|
||||
let index = 0;
|
||||
let currentItems = [];
|
||||
for (let emoji of emojis) {
|
||||
currentItems.push({ emojiOrEmojiInCategory: { emoji } });
|
||||
if (currentItems.length === this.emojiGridViewOwner.amountOfItemsPerRow) {
|
||||
value.push({ items: currentItems, index });
|
||||
currentItems = [];
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (currentItems.length > 0) {
|
||||
value.push({ items: currentItems, index });
|
||||
currentItems = [];
|
||||
index++;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
rows: many('EmojiGridRowView', {
|
||||
compute() {
|
||||
if (!this.emojiGridViewOwner) {
|
||||
return clear();
|
||||
}
|
||||
if (this.emojiGridViewOwnerAsNonSearch) {
|
||||
return this.computeNonSearchRows();
|
||||
}
|
||||
if (this.emojiGridViewOwnerAsSearch) {
|
||||
return this.computeSearchRows();
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'emojiGridViewRowRegistryOwner',
|
||||
sort: [['smaller-first', 'index']],
|
||||
}),
|
||||
emojiGridViewOwner: one('EmojiGridView', {
|
||||
compute() {
|
||||
return this.emojiGridViewOwnerAsNonSearch || this.emojiGridViewOwnerAsSearch;
|
||||
},
|
||||
}),
|
||||
emojiGridViewOwnerAsNonSearch: one('EmojiGridView', {
|
||||
identifying: true,
|
||||
inverse: 'nonSearchRowRegistry',
|
||||
}),
|
||||
emojiGridViewOwnerAsSearch: one('EmojiGridView', {
|
||||
identifying: true,
|
||||
inverse: 'searchRowRegistry',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiInCategory',
|
||||
fields: {
|
||||
category: one('EmojiCategory', {
|
||||
identifying: true,
|
||||
inverse: 'allEmojiInCategoryOfCurrent',
|
||||
}),
|
||||
emoji: one('Emoji', {
|
||||
identifying: true,
|
||||
inverse: 'allEmojiInCategoryOfCurrent',
|
||||
}),
|
||||
emojiOrEmojiInCategory: many('EmojiOrEmojiInCategory', {
|
||||
inverse: 'emojiInCategory',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiOrEmojiInCategory',
|
||||
identifyingMode: 'xor',
|
||||
fields: {
|
||||
emoji: one('Emoji', {
|
||||
identifying: true,
|
||||
inverse: 'emojiOrEmojiInCategory',
|
||||
}),
|
||||
emojiInCategory: one('EmojiInCategory', {
|
||||
identifying: true,
|
||||
inverse: 'emojiOrEmojiInCategory',
|
||||
}),
|
||||
emojiGridItemViews: many('EmojiGridItemView', {
|
||||
inverse: 'emojiOrEmojiInCategory',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiPickerHeaderActionListView',
|
||||
fields: {
|
||||
__dummyActionView: one('EmojiPickerHeaderActionView', {
|
||||
inverse: '__ownerAsDummy',
|
||||
}),
|
||||
actionViews: many('EmojiPickerHeaderActionView', {
|
||||
inverse: 'owner',
|
||||
sort: [['smaller-first', 'sequence']],
|
||||
}),
|
||||
emojiPickerView: one('EmojiPickerView', {
|
||||
related: 'owner.emojiPickerViewOwner',
|
||||
}),
|
||||
owner: one('EmojiPickerHeaderView', {
|
||||
identifying: true,
|
||||
inverse: 'actionListView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiPickerHeaderActionView',
|
||||
identifyingMode: 'xor',
|
||||
fields: {
|
||||
// dummy identifying field, so that it works without defining one initially in mail
|
||||
__ownerAsDummy: one('EmojiPickerHeaderActionListView', {
|
||||
identifying: true,
|
||||
inverse: '__dummyActionView',
|
||||
}),
|
||||
content: one('Record', {
|
||||
compute() {
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
contentComponentName: attr({
|
||||
compute() {
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
owner: one('EmojiPickerHeaderActionListView', {
|
||||
compute() {
|
||||
return clear();
|
||||
},
|
||||
inverse: 'actionViews',
|
||||
required: true,
|
||||
}),
|
||||
sequence: attr({
|
||||
default: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiPickerHeaderView',
|
||||
fields: {
|
||||
actionListView: one('EmojiPickerHeaderActionListView', {
|
||||
default: {},
|
||||
isCausal: true,
|
||||
inverse: 'owner',
|
||||
}),
|
||||
emojiCategoryBarView: one('EmojiCategoryBarView', {
|
||||
default: {},
|
||||
inverse: 'emojiPickerHeaderViewOwner',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
emojiPickerViewOwner: one('EmojiPickerView', {
|
||||
identifying: true,
|
||||
inverse: 'headerView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiPickerView',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
if (this.messaging.emojiRegistry.isLoaded || this.messaging.emojiRegistry.isLoading) {
|
||||
return;
|
||||
}
|
||||
this.messaging.emojiRegistry.loadEmojiData();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activeCategoryByGridViewScroll: one('EmojiPickerView.Category'),
|
||||
activeCategory: one('EmojiPickerView.Category', {
|
||||
compute() {
|
||||
if (this.emojiSearchBarView.currentSearch !== "") {
|
||||
return clear();
|
||||
}
|
||||
if (this.activeCategoryByGridViewScroll) {
|
||||
return this.activeCategoryByGridViewScroll;
|
||||
}
|
||||
if (this.defaultActiveCategory) {
|
||||
return this.defaultActiveCategory;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'emojiPickerViewAsActive',
|
||||
}),
|
||||
categories: many('EmojiPickerView.Category', {
|
||||
compute() {
|
||||
return this.messaging.emojiRegistry.allCategories.map(category => ({ category }));
|
||||
},
|
||||
inverse: 'emojiPickerViewOwner',
|
||||
}),
|
||||
defaultActiveCategory: one('EmojiPickerView.Category', {
|
||||
compute() {
|
||||
if (this.categories.length === 0) {
|
||||
return clear();
|
||||
}
|
||||
return this.categories[0];
|
||||
},
|
||||
}),
|
||||
emojiGridView: one('EmojiGridView', {
|
||||
default: {},
|
||||
inverse: 'emojiPickerViewOwner',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
emojiSearchBarView: one('EmojiSearchBarView', {
|
||||
default: {},
|
||||
inverse: 'emojiPickerView',
|
||||
readonly: true,
|
||||
}),
|
||||
headerView: one('EmojiPickerHeaderView', {
|
||||
default: {},
|
||||
inverse: 'emojiPickerViewOwner',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
popoverViewOwner: one('PopoverView', {
|
||||
identifying: true,
|
||||
inverse: 'emojiPickerView',
|
||||
}),
|
||||
component: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
/**
|
||||
* Emoji category info of a specific emoji picker view
|
||||
*/
|
||||
registerModel({
|
||||
name: 'EmojiPickerView.Category',
|
||||
fields: {
|
||||
category: one('EmojiCategory', {
|
||||
identifying: true,
|
||||
inverse: 'allEmojiPickerViewCategory',
|
||||
}),
|
||||
emojiPickerViewOwner: one('EmojiPickerView', {
|
||||
identifying: true,
|
||||
inverse: 'categories',
|
||||
}),
|
||||
emojiPickerViewAsActive: one('EmojiPickerView', {
|
||||
inverse: 'activeCategory',
|
||||
}),
|
||||
emojiCategoryView: one('EmojiCategoryView', {
|
||||
inverse: 'viewCategory',
|
||||
}),
|
||||
emojiGridRowView: one('EmojiGridRowView', {
|
||||
inverse: 'viewCategory',
|
||||
}),
|
||||
emojiPickerViewOwnerAsLastCategory: one('EmojiPickerView', {
|
||||
compute() {
|
||||
if (this.emojiPickerViewOwner.categories[this.emojiPickerViewOwner.categories.length - 1] === this) {
|
||||
return this.emojiPickerViewOwner;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
endSectionIndex: attr({
|
||||
compute() {
|
||||
if (!this.nextViewCategory || !this.nextViewCategory.emojiGridRowView) {
|
||||
return clear();
|
||||
}
|
||||
return this.nextViewCategory.emojiGridRowView.index - 1;
|
||||
},
|
||||
default: 0,
|
||||
}),
|
||||
nextViewCategory: one('EmojiPickerView.Category', {
|
||||
compute() {
|
||||
const index = this.emojiPickerViewOwner.categories.findIndex(category => category === this);
|
||||
if (index === -1) {
|
||||
return clear();
|
||||
}
|
||||
if (index === this.emojiPickerViewOwner.categories.length - 1) {
|
||||
return clear();
|
||||
}
|
||||
return this.emojiPickerViewOwner.categories[index + 1];
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many } from '@mail/model/model_field';
|
||||
import { insert } from '@mail/model/model_field_command';
|
||||
import { getBundle, loadBundle } from '@web/core/assets';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiRegistry',
|
||||
recordMethods: {
|
||||
async loadEmojiData() {
|
||||
this.update({ isLoading: true });
|
||||
await getBundle('mail.assets_model_data').then(loadBundle);
|
||||
const { emojiCategoriesData, emojisData } = await odoo.runtimeImport("@mail/models_data/emoji_data");
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this._populateFromEmojiData(emojiCategoriesData, emojisData);
|
||||
},
|
||||
async _populateFromEmojiData(dataCategories, dataEmojis) {
|
||||
dataCategories.map(category => {
|
||||
const emojiCount = dataEmojis.reduce((acc, emoji) => emoji.category === category.name ? acc + 1 : acc, 0);
|
||||
this.update({
|
||||
dataCategories: insert({
|
||||
name: category.name,
|
||||
displayName: category.displayName,
|
||||
title: category.title,
|
||||
sortId: category.sortId,
|
||||
emojiCount,
|
||||
}),
|
||||
});
|
||||
});
|
||||
this.models['Emoji'].insert(dataEmojis.map(emojiData => ({
|
||||
codepoints: emojiData.codepoints,
|
||||
shortcodes: emojiData.shortcodes,
|
||||
emoticons: emojiData.emoticons,
|
||||
name: emojiData.name,
|
||||
keywords: emojiData.keywords,
|
||||
emojiDataCategory: { name: emojiData.category },
|
||||
})));
|
||||
this.update({
|
||||
isLoaded: true,
|
||||
isLoading: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
allCategories: many('EmojiCategory', {
|
||||
compute() {
|
||||
return this.dataCategories;
|
||||
},
|
||||
inverse: 'emojiRegistry',
|
||||
sort: [['smaller-first', 'sortId']],
|
||||
}),
|
||||
allEmojis: many('Emoji', {
|
||||
inverse: 'emojiRegistry',
|
||||
sort: [['smaller-first', 'codepoints']],
|
||||
}),
|
||||
dataCategories: many('EmojiCategory'),
|
||||
isLoaded: attr({
|
||||
default: false,
|
||||
}),
|
||||
isLoading: attr({
|
||||
default: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiSearchBarView',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
if (!this.messaging.device.isSmall) {
|
||||
this.update({ isDoFocus: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* Handles OWL update on this EmojiSearchBarView component.
|
||||
*/
|
||||
onComponentUpdate() {
|
||||
this._handleFocus();
|
||||
},
|
||||
onFocusinInput() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ isFocused: true });
|
||||
},
|
||||
onFocusoutInput() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ isFocused: false });
|
||||
},
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
onInput() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({
|
||||
currentSearch: this.inputRef.el.value,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
reset() {
|
||||
this.update({ currentSearch: "" });
|
||||
this.inputRef.el.value = "";
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_handleFocus() {
|
||||
if (this.isDoFocus) {
|
||||
if (!this.inputRef.el) {
|
||||
return;
|
||||
}
|
||||
this.update({ isDoFocus: false });
|
||||
this.inputRef.el.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
currentSearch: attr({
|
||||
default: "",
|
||||
}),
|
||||
emojiPickerView: one("EmojiPickerView", {
|
||||
identifying: true,
|
||||
inverse: "emojiSearchBarView",
|
||||
}),
|
||||
inputRef: attr(),
|
||||
isDoFocus: attr({
|
||||
default: false,
|
||||
}),
|
||||
isFocused: attr({
|
||||
default: false,
|
||||
}),
|
||||
placeholder: attr({
|
||||
compute() {
|
||||
return this.env._t("Search an emoji");
|
||||
},
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'EmojiView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
if (!this.emojiGridItemViewOwner.emojiGridRowViewOwner) {
|
||||
return;
|
||||
}
|
||||
if (this.emojiGridItemViewOwner.emojiGridRowViewOwner.emojiGridViewOwner.emojiPickerViewOwner.popoverViewOwner.messageActionViewOwnerAsReaction) {
|
||||
this.emojiGridItemViewOwner.emojiGridRowViewOwner.emojiGridViewOwner.emojiPickerViewOwner.popoverViewOwner.messageActionViewOwnerAsReaction.onClickReaction(ev);
|
||||
return;
|
||||
}
|
||||
if (this.emojiGridItemViewOwner.emojiGridRowViewOwner.emojiGridViewOwner.emojiPickerViewOwner.popoverViewOwner.composerViewOwnerAsEmoji) {
|
||||
this.emojiGridItemViewOwner.emojiGridRowViewOwner.emojiGridViewOwner.emojiPickerViewOwner.popoverViewOwner.composerViewOwnerAsEmoji.onClickEmoji(ev);
|
||||
return;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseenter(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ emojiGridViewAsHovered: this.emojiGridItemViewOwner.emojiGridRowViewOwner.emojiGridViewOwner });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseleave(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ emojiGridViewAsHovered: clear() });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
emoji: one('Emoji', {
|
||||
identifying: true,
|
||||
inverse: 'emojiViews',
|
||||
}),
|
||||
emojiGridItemViewOwner: one('EmojiGridItemView', {
|
||||
identifying: true,
|
||||
inverse: 'emojiView',
|
||||
}),
|
||||
emojiGridViewAsHovered: one('EmojiGridView', {
|
||||
inverse: 'hoveredEmojiView',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import core from 'web.core';
|
||||
|
||||
const getAttachmentNextTemporaryId = (function () {
|
||||
let tmpId = 0;
|
||||
return () => {
|
||||
tmpId -= 1;
|
||||
return tmpId;
|
||||
};
|
||||
})();
|
||||
|
||||
registerModel({
|
||||
name: 'FileUploader',
|
||||
identifyingMode: 'xor',
|
||||
recordMethods: {
|
||||
openBrowserFileUploader() {
|
||||
this.fileInput.click();
|
||||
},
|
||||
/**
|
||||
* Called when there are changes in the file input.
|
||||
*
|
||||
* @param {Event} ev
|
||||
* @param {EventTarget} ev.target
|
||||
* @param {FileList|Array} ev.target.files
|
||||
*/
|
||||
onChangeAttachment(ev) {
|
||||
this.uploadFiles(ev.target.files);
|
||||
},
|
||||
/**
|
||||
* @param {FileList|Array} files
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async uploadFiles(files) {
|
||||
await this._performUpload({ files });
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.chatterOwner && !this.chatterOwner.attachmentBoxView) {
|
||||
this.chatterOwner.openAttachmentBoxView();
|
||||
}
|
||||
this.messaging.messagingBus.trigger('o-file-uploader-upload', { files });
|
||||
// clear at the end because side-effect of emptying `files`
|
||||
this.fileInput.value = '';
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {Composer} param0.composer
|
||||
* @param {File} param0.file
|
||||
* @param {Thread} param0.thread
|
||||
* @returns {FormData}
|
||||
*/
|
||||
_createFormData({ composer, file, thread }) {
|
||||
const formData = new window.FormData();
|
||||
formData.append('csrf_token', core.csrf_token);
|
||||
formData.append('is_pending', Boolean(composer));
|
||||
formData.append('thread_id', thread && thread.id);
|
||||
formData.append('thread_model', thread && thread.model);
|
||||
formData.append('ufile', file, file.name);
|
||||
return formData;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {Object} param0.attachmentData
|
||||
* @param {Composer} param0.composer
|
||||
* @param {Thread} param0.thread
|
||||
* @returns {Attachment}
|
||||
*/
|
||||
async _onAttachmentUploaded({ attachmentData, composer, thread }) {
|
||||
if (attachmentData.error || !attachmentData.id) {
|
||||
this.messaging.notify({
|
||||
type: 'danger',
|
||||
message: attachmentData.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return {
|
||||
composer: composer,
|
||||
originThread: (!composer && thread) ? thread : undefined,
|
||||
...attachmentData,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} param0
|
||||
* @param {FileList|Array} param0.files
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _performUpload({ files }) {
|
||||
const webRecord = this.activityListViewItemOwner && this.activityListViewItemOwner.webRecord;
|
||||
const composer = this.composerView && this.composerView.composer; // save before async
|
||||
const thread = this.thread; // save before async
|
||||
const chatter = (
|
||||
(this.chatterOwner) ||
|
||||
(this.attachmentBoxView && this.attachmentBoxView.chatter) ||
|
||||
(this.activityView && this.activityView.activityBoxView.chatter)
|
||||
); // save before async
|
||||
const activity = (
|
||||
this.activityView && this.activityView.activity ||
|
||||
this.activityListViewItemOwner && this.activityListViewItemOwner.activity
|
||||
); // save before async
|
||||
const uploadingAttachments = new Map();
|
||||
for (const file of files) {
|
||||
uploadingAttachments.set(file, this.messaging.models['Attachment'].insert({
|
||||
composer,
|
||||
filename: file.name,
|
||||
id: getAttachmentNextTemporaryId(),
|
||||
isUploading: true,
|
||||
mimetype: file.type,
|
||||
name: file.name,
|
||||
originThread: (!composer && thread) ? thread : undefined,
|
||||
}));
|
||||
}
|
||||
const attachments = [];
|
||||
const uploadedAttachments = [];
|
||||
for (const file of files) {
|
||||
const uploadingAttachment = uploadingAttachments.get(file);
|
||||
if (!uploadingAttachment.exists()) {
|
||||
// This happens when a pending attachment is being deleted by user before upload.
|
||||
continue;
|
||||
}
|
||||
if ((composer && !composer.exists()) || (thread && !thread.exists())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = this._createFormData({ composer, file, thread });
|
||||
if (activity) {
|
||||
body.append("activity_id", activity.id);
|
||||
}
|
||||
const response = await (composer || thread).messaging.browser.fetch('/mail/attachment/upload', {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: uploadingAttachment.uploadingAbortController.signal,
|
||||
});
|
||||
const attachmentData = await response.json();
|
||||
if (uploadingAttachment.exists()) {
|
||||
uploadingAttachment.delete();
|
||||
}
|
||||
if ((composer && !composer.exists()) || (thread && !thread.exists())) {
|
||||
return;
|
||||
}
|
||||
const attachment = await this._onAttachmentUploaded({ attachmentData, composer, thread });
|
||||
if (attachment) {
|
||||
uploadedAttachments.push(attachment);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const data of uploadedAttachments) {
|
||||
const attachment = (composer || thread).messaging.models['Attachment'].insert(data);
|
||||
attachments.push(attachment);
|
||||
}
|
||||
if (activity && activity.exists()) {
|
||||
await activity.markAsDone({ attachments });
|
||||
}
|
||||
if (webRecord) {
|
||||
webRecord.model.load({ offset: webRecord.model.root.offset });
|
||||
}
|
||||
if (chatter && chatter.exists() && chatter.shouldReloadParentFromFileChanged) {
|
||||
chatter.reloadParentView();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
activityListViewItemOwner: one('ActivityListViewItem', {
|
||||
identifying: true,
|
||||
inverse: 'fileUploader',
|
||||
}),
|
||||
activityView: one('ActivityView', {
|
||||
identifying: true,
|
||||
inverse: 'fileUploader',
|
||||
}),
|
||||
attachmentBoxView: one('AttachmentBoxView', {
|
||||
identifying: true,
|
||||
inverse: 'fileUploader',
|
||||
}),
|
||||
chatterOwner: one('Chatter', {
|
||||
identifying: true,
|
||||
inverse: 'fileUploader',
|
||||
}),
|
||||
composerView: one('ComposerView', {
|
||||
identifying: true,
|
||||
inverse: 'fileUploader',
|
||||
}),
|
||||
fileInput: attr({
|
||||
/**
|
||||
* Create an HTML element that will serve as file input.
|
||||
* This element does not need to be inserted in the DOM since it's just
|
||||
* use to trigger the file browser and start the upload process.
|
||||
*/
|
||||
compute() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.multiple = true;
|
||||
fileInput.onchange = this.onChangeAttachment;
|
||||
return fileInput;
|
||||
},
|
||||
}),
|
||||
thread: one('Thread', {
|
||||
compute() {
|
||||
if (this.activityView) {
|
||||
return this.activityView.activity.thread;
|
||||
}
|
||||
if (this.activityListViewItemOwner) {
|
||||
return this.activityListViewItemOwner.activity.thread;
|
||||
}
|
||||
if (this.attachmentBoxView) {
|
||||
return this.attachmentBoxView.chatter.thread;
|
||||
}
|
||||
if (this.chatterOwner) {
|
||||
return this.chatterOwner.thread;
|
||||
}
|
||||
if (this.composerView) {
|
||||
return this.composerView.composer.activeThread;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'FollowButtonView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
async onClickFollow(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
const chatter = this.chatterOwner;
|
||||
if (!chatter) {
|
||||
return;
|
||||
}
|
||||
if (chatter.isTemporary) {
|
||||
const saved = await chatter.doSaveRecord();
|
||||
if (!saved || chatter.thread.isCurrentPartnerFollowing) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
chatter.thread.follow();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickUnfollow(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (!this.chatterOwner || !this.chatterOwner.thread) {
|
||||
return;
|
||||
}
|
||||
this.chatterOwner.thread.unfollow();
|
||||
this.chatterOwner.reloadParentView({ fieldNames: ['message_follower_ids'] });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseEnterUnfollow(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ isUnfollowButtonHighlighted: true });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onMouseleaveUnfollow(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ isUnfollowButtonHighlighted: false });
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
chatterOwner: one('Chatter', {
|
||||
identifying: true,
|
||||
inverse: 'followButtonView',
|
||||
}),
|
||||
followingText: attr({
|
||||
compute() {
|
||||
return this.env._t("Following");
|
||||
},
|
||||
}),
|
||||
isDisabled: attr({
|
||||
compute() {
|
||||
if (!this.chatterOwner) {
|
||||
return clear();
|
||||
}
|
||||
return !this.chatterOwner.isTemporary && !this.chatterOwner.hasReadAccess;
|
||||
},
|
||||
}),
|
||||
isUnfollowButtonHighlighted: attr({
|
||||
default: false,
|
||||
}),
|
||||
unfollowingText: attr({
|
||||
compute() {
|
||||
return this.env._t("Unfollow");
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
192
odoo-bringout-oca-ocb-mail/mail/static/src/models/follower.js
Normal file
192
odoo-bringout-oca-ocb-mail/mail/static/src/models/follower.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear, insert, link, unlink } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'Follower',
|
||||
modelMethods: {
|
||||
/**
|
||||
* @param {Object} data
|
||||
* @returns {Object}
|
||||
*/
|
||||
convertData(data) {
|
||||
const data2 = {};
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
if ('is_active' in data) {
|
||||
data2.isActive = data.is_active;
|
||||
}
|
||||
if ('partner_id' in data) {
|
||||
if (!data.partner_id) {
|
||||
data2.partner = clear();
|
||||
} else {
|
||||
const partnerData = {
|
||||
display_name: data.display_name,
|
||||
email: data.email,
|
||||
id: data.partner_id,
|
||||
name: data.name,
|
||||
};
|
||||
data2.partner = insert(partnerData);
|
||||
}
|
||||
}
|
||||
if (data.partner) {
|
||||
data2.partner = data.partner;
|
||||
}
|
||||
return data2;
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* Close subtypes dialog
|
||||
*/
|
||||
closeSubtypes() {
|
||||
this.update({ followerSubtypeListDialog: clear() });
|
||||
},
|
||||
/**
|
||||
* Opens the most appropriate view that is a profile for this follower.
|
||||
*/
|
||||
async openProfile() {
|
||||
return this.partner.openProfile();
|
||||
},
|
||||
/**
|
||||
* Remove this follower from its related thread.
|
||||
*/
|
||||
async remove() {
|
||||
const partner_ids = [];
|
||||
partner_ids.push(this.partner.id);
|
||||
const followedThread = this.followedThread;
|
||||
await this.messaging.rpc({
|
||||
model: this.followedThread.model,
|
||||
method: 'message_unsubscribe',
|
||||
args: [[this.followedThread.id], partner_ids]
|
||||
});
|
||||
if (followedThread.exists()) {
|
||||
followedThread.fetchData(['suggestedRecipients']);
|
||||
}
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.delete();
|
||||
},
|
||||
/**
|
||||
* @param {FollowerSubtype} subtype
|
||||
*/
|
||||
selectSubtype(subtype) {
|
||||
if (!this.selectedSubtypes.includes(subtype)) {
|
||||
this.update({ selectedSubtypes: link(subtype) });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Show (editable) list of subtypes of this follower.
|
||||
*/
|
||||
async showSubtypes() {
|
||||
const subtypesData = await this.messaging.rpc({
|
||||
route: '/mail/read_subscription_data',
|
||||
params: { follower_id: this.id },
|
||||
});
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.update({ subtypes: clear() });
|
||||
for (const data of subtypesData) {
|
||||
const subtype = this.messaging.models['FollowerSubtype'].insert(
|
||||
this.messaging.models['FollowerSubtype'].convertData(data)
|
||||
);
|
||||
this.update({ subtypes: link(subtype) });
|
||||
if (data.followed) {
|
||||
this.update({ selectedSubtypes: link(subtype) });
|
||||
} else {
|
||||
this.update({ selectedSubtypes: unlink(subtype) });
|
||||
}
|
||||
}
|
||||
this.update({ followerSubtypeListDialog: {} });
|
||||
},
|
||||
/**
|
||||
* @param {FollowerSubtype} subtype
|
||||
*/
|
||||
unselectSubtype(subtype) {
|
||||
if (this.selectedSubtypes.includes(subtype)) {
|
||||
this.update({ selectedSubtypes: unlink(subtype) });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Update server-side subscription of subtypes of this follower.
|
||||
*/
|
||||
async updateSubtypes() {
|
||||
if (this.selectedSubtypes.length === 0) {
|
||||
this.remove();
|
||||
} else {
|
||||
const kwargs = {
|
||||
subtype_ids: this.selectedSubtypes.map(subtype => subtype.id),
|
||||
};
|
||||
if (this.partner) {
|
||||
kwargs.partner_ids = [this.partner.id];
|
||||
}
|
||||
await this.messaging.rpc({
|
||||
model: this.followedThread.model,
|
||||
method: 'message_subscribe',
|
||||
args: [[this.followedThread.id]],
|
||||
kwargs,
|
||||
});
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.messaging.notify({
|
||||
type: 'success',
|
||||
message: this.env._t("The subscription preferences were successfully applied."),
|
||||
});
|
||||
}
|
||||
this.closeSubtypes();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
followedThread: one('Thread', {
|
||||
inverse: 'followers',
|
||||
}),
|
||||
followedThreadAsFollowerOfCurrentPartner: one('Thread', {
|
||||
compute() {
|
||||
if (!this.followedThread) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.messaging.currentPartner) {
|
||||
return clear();
|
||||
}
|
||||
if (this.partner === this.messaging.currentPartner) {
|
||||
return this.followedThread;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'followerOfCurrentPartner',
|
||||
}),
|
||||
followerSubtypeListDialog: one('Dialog', {
|
||||
inverse: 'followerOwnerAsSubtypeList',
|
||||
}),
|
||||
followerViews: many('FollowerView', {
|
||||
inverse: 'follower',
|
||||
}),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
isActive: attr({
|
||||
default: true,
|
||||
}),
|
||||
/**
|
||||
* States whether the follower's subtypes are editable by current user.
|
||||
*/
|
||||
isEditable: attr({
|
||||
compute() {
|
||||
const hasWriteAccess = this.followedThread ? this.followedThread.hasWriteAccess : false;
|
||||
const hasReadAccess = this.followedThread ? this.followedThread.hasReadAccess : false;
|
||||
return this.messaging.currentPartner === this.partner ? hasReadAccess : hasWriteAccess;
|
||||
},
|
||||
}),
|
||||
partner: one('Partner', {
|
||||
required: true,
|
||||
}),
|
||||
selectedSubtypes: many('FollowerSubtype'),
|
||||
subtypes: many('FollowerSubtype'),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'FollowerListMenuView',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
document.addEventListener('click', this._onClickCaptureGlobal, true);
|
||||
},
|
||||
_willDelete() {
|
||||
document.removeEventListener('click', this._onClickCaptureGlobal, true);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
hide() {
|
||||
this.update({ isDropdownOpen: false });
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickAddFollowers(ev) {
|
||||
ev.preventDefault();
|
||||
this.hide();
|
||||
this.chatterOwner.promptAddPartnerFollower();
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickFollowersButton(ev) {
|
||||
this.update({ isDropdownOpen: !this.isDropdownOpen });
|
||||
},
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onKeydown(ev) {
|
||||
ev.stopPropagation();
|
||||
switch (ev.key) {
|
||||
case 'Escape':
|
||||
ev.preventDefault();
|
||||
this.hide();
|
||||
break;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Close the dropdown when clicking outside of it.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickCaptureGlobal(ev) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
// since dropdown is conditionally shown based on state, dropdownRef can be null
|
||||
if (this.dropdownRef && this.dropdownRef.el && !this.dropdownRef.el.contains(ev.target)) {
|
||||
this.hide();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
chatterOwner: one('Chatter', {
|
||||
identifying: true,
|
||||
inverse: 'followerListMenuView',
|
||||
}),
|
||||
dropdownRef: attr(),
|
||||
followerViews: many('FollowerView', {
|
||||
compute() {
|
||||
return this.chatterOwner.thread.followers.map(follower => ({ follower }));
|
||||
},
|
||||
inverse: 'followerListMenuViewOwner',
|
||||
}),
|
||||
isDisabled: attr({
|
||||
compute() {
|
||||
return !this.chatterOwner.hasReadAccess;
|
||||
}
|
||||
}),
|
||||
isDropdownOpen: attr({
|
||||
default: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'FollowerSubtype',
|
||||
modelMethods: {
|
||||
/**
|
||||
* @param {Object} data
|
||||
* @returns {Object}
|
||||
*/
|
||||
convertData(data) {
|
||||
const data2 = {};
|
||||
if ('default' in data) {
|
||||
data2.isDefault = data.default;
|
||||
}
|
||||
if ('id' in data) {
|
||||
data2.id = data.id;
|
||||
}
|
||||
if ('internal' in data) {
|
||||
data2.isInternal = data.internal;
|
||||
}
|
||||
if ('name' in data) {
|
||||
data2.name = data.name;
|
||||
}
|
||||
if ('parent_model' in data) {
|
||||
data2.parentModel = data.parent_model;
|
||||
}
|
||||
if ('res_model' in data) {
|
||||
data2.resModel = data.res_model;
|
||||
}
|
||||
if ('sequence' in data) {
|
||||
data2.sequence = data.sequence;
|
||||
}
|
||||
return data2;
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
followerSubtypeViews: many('FollowerSubtypeView', {
|
||||
inverse: 'subtype',
|
||||
}),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
isDefault: attr({
|
||||
default: false,
|
||||
}),
|
||||
isInternal: attr({
|
||||
default: false,
|
||||
}),
|
||||
name: attr(),
|
||||
// AKU FIXME: use relation instead
|
||||
parentModel: attr(),
|
||||
// AKU FIXME: use relation instead
|
||||
resModel: attr(),
|
||||
sequence: attr({
|
||||
default: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerModel({
|
||||
name: 'FollowerSubtypeList',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Returns whether the given html element is inside this follower subtype list.
|
||||
*
|
||||
* @param {Element} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
containsElement(element) {
|
||||
return Boolean(this.component && this.component.root.el && this.component.root.el.contains(element));
|
||||
},
|
||||
/**
|
||||
* Called when clicking on apply button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickApply(ev) {
|
||||
this.follower.updateSubtypes();
|
||||
},
|
||||
/**
|
||||
* Called when clicking on cancel button.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickCancel(ev) {
|
||||
this.follower.closeSubtypes();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* States the OWL component of this attachment viewer.
|
||||
*/
|
||||
component: attr(),
|
||||
/**
|
||||
* States the dialog displaying this follower subtype list.
|
||||
*/
|
||||
dialogOwner: one('Dialog', {
|
||||
identifying: true,
|
||||
inverse: 'followerSubtypeList',
|
||||
isCausal: true,
|
||||
}),
|
||||
follower: one('Follower', {
|
||||
related: 'dialogOwner.followerOwnerAsSubtypeList',
|
||||
required: true,
|
||||
}),
|
||||
followerSubtypeViews: many('FollowerSubtypeView', {
|
||||
compute() {
|
||||
if (this.follower.subtypes.length === 0) {
|
||||
return clear();
|
||||
}
|
||||
return this.follower.subtypes.map(subtype => ({ subtype }));
|
||||
},
|
||||
inverse: 'followerSubtypeListOwner',
|
||||
sort: [
|
||||
['falsy-first', 'subtype.parentModel'],
|
||||
['case-insensitive-asc', 'subtype.parentModel'],
|
||||
['falsy-first', 'subtype.resModel'],
|
||||
['case-insensitive-asc', 'subtype.resModel'],
|
||||
['smaller-first', 'subtype.isInternal'],
|
||||
['smaller-first', 'subtype.sequence'],
|
||||
['smaller-first', 'subtype.id'],
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue