Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View 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',
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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',
},
],
});

View file

@ -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',
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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();
},
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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);
},
}),
},
});

View 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(),
},
});

View file

@ -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,
}),
},
});

View file

@ -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;
},
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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);
},
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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;
},
}),
},
});

View file

@ -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();
},
}),
},
});

View file

@ -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',
},
],
});

View file

@ -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,
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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(),
},
});

View file

@ -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',
})
},
});

View file

@ -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();
},
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View 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',
},
],
});

View file

@ -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,
}),
},
});

View 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;
},
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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,
}
);
},
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View 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;
},
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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,
}),
},
});

View 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',
},
],
});

View file

@ -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',
}),
},
});

View 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',
},
],
});

View file

@ -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',
}),
},
});

View 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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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);
},
})
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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: "",
}),
},
});

File diff suppressed because it is too large Load diff

View 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(),
},
});

View file

@ -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,
}),
},
});

View 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});`;
},
}),
},
});

View file

@ -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,
}),
},
});

View 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,
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
},
],
});

View file

@ -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'
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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',
},
],
});

View file

@ -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,
}),
},
});

View 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];
},
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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...");
},
}),
},
});

View file

@ -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");
},
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
},
],
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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(),
},
});

View file

@ -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];
},
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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',
}),
},
});

View file

@ -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,
})
},
});

View file

@ -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");
},
}),
},
});

View 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'),
},
});

View file

@ -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,
}),
},
});

View file

@ -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,
}),
},
});

View file

@ -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