diff --git a/client/src/collections/note.js b/client/src/collections/note.js
index 4c4a471839..00f62ec88c 100644
--- a/client/src/collections/note.js
+++ b/client/src/collections/note.js
@@ -34,11 +34,22 @@ class NoteCollection extends Collection {
paginationByNumber = false
+ /**
+ * @private
+ * @type {string|null}
+ */
+ reactionsCheckDate = null
+
/**
* @type {Record[]}
*/
pinnedList
+ /**
+ * @type {number}
+ */
+ reactionsCheckMaxSize = 0;
+
/** @inheritDoc */
prepareAttributes(response, params) {
const total = this.total;
@@ -54,6 +65,24 @@ class NoteCollection extends Collection {
this.pinnedList = Espo.Utils.cloneDeep(response.pinnedList);
}
+ this.reactionsCheckDate = response.reactionsCheckDate;
+
+ /** @type {Record[]} */
+ const updatedReactions = response.updatedReactions;
+
+ if (updatedReactions) {
+ updatedReactions.forEach(item => {
+ const model = this.get(item.id);
+
+ if (!model) {
+ return;
+ }
+
+ model.set(item, {keepRowActions: true});
+
+ });
+ }
+
return list;
}
@@ -76,6 +105,16 @@ class NoteCollection extends Collection {
options.remove = false;
options.at = 0;
options.maxSize = null;
+
+ if (this.reactionsCheckMaxSize) {
+ options.data.reactionsAfter = this.reactionsCheckDate || options.data.after;
+
+ options.data.reactionsCheckNoteIds = this.models
+ .filter(model => model.attributes.type === 'Post')
+ .map(model => model.id)
+ .slice(0, this.reactionsCheckMaxSize)
+ .join(',');
+ }
}
return this.fetch(options);
diff --git a/client/src/handlers/note/record-detail-setup.js b/client/src/handlers/note/record-detail-setup.js
new file mode 100644
index 0000000000..d3b9a2ab0b
--- /dev/null
+++ b/client/src/handlers/note/record-detail-setup.js
@@ -0,0 +1,55 @@
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU Affero General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+export default class {
+
+ /**
+ * @param {import('views/record/detail').default} view
+ */
+ constructor(view) {
+ this.view = view;
+ }
+
+ process() {
+ this.controlFields();
+
+ this.view.listenTo(this.view.model, 'sync', () => this.controlFields());
+ }
+
+ controlFields() {
+ const attachmentsIds = this.view.model.attributes.attachmentsIds;
+
+ if (!attachmentsIds || !attachmentsIds.length) {
+ this.view.hideField('attachments');
+
+ return;
+ }
+
+ this.view.showField('attachments');
+ }
+}
diff --git a/client/src/helpers/misc/reactions.js b/client/src/helpers/misc/reactions.js
new file mode 100644
index 0000000000..17e6087016
--- /dev/null
+++ b/client/src/helpers/misc/reactions.js
@@ -0,0 +1,83 @@
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU Affero General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+class ReactionsHelper {
+
+ /**
+ * @param {import('models/settings').default} config
+ * @param {import('metadata').default} metadata
+ */
+ constructor(config, metadata) {
+ /** @private */
+ this.config = config;
+ /** @private */
+ this.metadata = metadata;
+
+ /**
+ * @private
+ * @type {{
+ * type: string,
+ * iconClass: string,
+ * }[]}
+ */
+ this.list = metadata.get('app.reactions.list') || [];
+ }
+
+ /**
+ * @return {{
+ * type: string,
+ * iconClass: string,
+ * }[]}
+ */
+ getDefinitionList() {
+ return this.list;
+ }
+
+ /**
+ * @return {string[]}
+ */
+ getAvailableReactions() {
+ return this.config.get('availableReactions') || []
+ }
+
+ /**
+ * @param {string|null} type
+ * @return {string|null}
+ */
+ getIconClass(type) {
+ const item = this.list.find(it => it.type === type);
+
+ if (!item) {
+ return null;
+ }
+
+ return item.iconClass;
+ }
+}
+
+export default ReactionsHelper;
diff --git a/client/src/ui.js b/client/src/ui.js
index d1a3f313c8..fc6a0466f5 100644
--- a/client/src/ui.js
+++ b/client/src/ui.js
@@ -902,6 +902,8 @@ Espo.Ui = {
* @property {boolean} [noHideOnOutsideClick=false] Don't hide on clicking outside.
* @property {function(): void} [onShow] On-show callback.
* @property {function(): void} [onHide] On-hide callback.
+ * @property {string|function(): string} [title] A title text.
+ * @property {boolean} [keepElementTitle] Keep an original element's title.
*/
/**
@@ -910,7 +912,12 @@ Espo.Ui = {
* @param {Element|JQuery} element An element.
* @param {Espo.Ui~PopoverOptions} o Options.
* @param {module:view} [view] A view.
- * @return {{hide: function(), destroy: function(), show: function(), detach: function()}}
+ * @return {{
+ * hide: function(),
+ * destroy: function(),
+ * show: function(): string,
+ * detach: function(),
+ * }}
*/
popover: function (element, o, view) {
const $el = $(element);
@@ -935,6 +942,8 @@ Espo.Ui = {
html: true,
content: content,
trigger: o.trigger || 'manual',
+ title: o.title,
+ keepElementTitle: o.keepElementTitle,
})
.on('shown.bs.popover', () => {
isShown = true;
@@ -1021,6 +1030,8 @@ Espo.Ui = {
const show = () => {
// noinspection JSUnresolvedReference
$el.popover('show');
+
+ return $el.attr('aria-describedby');
};
if (view) {
diff --git a/client/src/views/modal.js b/client/src/views/modal.js
index b2fddc5764..4498992bfd 100644
--- a/client/src/views/modal.js
+++ b/client/src/views/modal.js
@@ -387,6 +387,10 @@ class ModalView extends View {
if (!this.noFullHeight) {
this.initBodyScrollListener();
}
+
+ if (this.getParentView()) {
+ this.getParentView().trigger('modal-shown');
+ }
});
this.once('remove', () => {
diff --git a/client/src/views/notification/fields/container.js b/client/src/views/notification/fields/container.js
index c47e1b39f4..4e253918a2 100644
--- a/client/src/views/notification/fields/container.js
+++ b/client/src/views/notification/fields/container.js
@@ -41,10 +41,11 @@ class NotificationContainerFieldView extends BaseFieldView {
'EntityRemoved',
'Message',
'System',
+ 'UserReaction',
]
setup() {
- switch (this.model.get('type')) {
+ switch (this.model.attributes.type) {
case 'Note':
this.processNote(this.model.get('noteData'));
diff --git a/client/src/views/notification/items/base.js b/client/src/views/notification/items/base.js
index bbb1fedf40..34ebb3e07b 100644
--- a/client/src/views/notification/items/base.js
+++ b/client/src/views/notification/items/base.js
@@ -111,14 +111,22 @@ class BaseNotificationItemView extends View {
string = string.toLowerCase();
- const language = this.getPreferences().get('language') || this.getConfig().get('language');
-
- if (['de_DE', 'nl_NL'].includes(language)) {
+ if (this.toUpperCaseFirstLetter()) {
string = Espo.Utils.upperCaseFirst(string);
}
return string;
}
+
+ /**
+ * @property
+ * @return {boolean}
+ */
+ toUpperCaseFirstLetter() {
+ const language = this.getPreferences().get('language') || this.getConfig().get('language');
+
+ return ['de_DE', 'nl_NL'].includes(language);
+ }
}
export default BaseNotificationItemView;
diff --git a/client/src/views/notification/items/user-reaction.js b/client/src/views/notification/items/user-reaction.js
new file mode 100644
index 0000000000..53656998b7
--- /dev/null
+++ b/client/src/views/notification/items/user-reaction.js
@@ -0,0 +1,120 @@
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU Affero General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+import BaseNotificationItemView from 'views/notification/items/base';
+import ReactionsHelper from 'helpers/misc/reactions';
+
+// noinspection JSUnusedGlobalSymbols
+export default class UserReactionNotificationItemView extends BaseNotificationItemView {
+
+ // language=Handlebars
+ templateContent = `
+
+
+ {{{avatar}}}
+
+
+
+
+ {{{message}}}
+
+
+
+
+
+ {{{createdAt}}}
+
+ `
+
+ messageName = 'userPostReaction'
+
+ /**
+ * @private
+ * @type {string|null}
+ */
+ reactionIconClass
+
+
+ data() {
+ return {
+ ...super.data(),
+ reactionIconClass: this.reactionIconClass,
+ };
+ }
+
+ setup() {
+ const data = /** @type {Object.} */this.model.attributes.data || {};
+
+ const relatedParentId = this.model.attributes.relatedParentId;
+ const relatedParentType = this.model.attributes.relatedParentType;
+
+ this.userId = this.model.attributes.createdById;
+
+ this.messageData['type'] = this.translate(data.type, 'reactions');
+
+ const reactionsHelper = new ReactionsHelper(this.getConfig(), this.getMetadata());
+ this.reactionIconClass = reactionsHelper.getIconClass(data.type);
+
+ const userElement = document.createElement('a');
+ userElement.href = `#User/view/${this.model.attributes.createdById}`;
+ userElement.dataset.id = this.model.attributes.createdById;
+ userElement.dataset.scope = 'User';
+ userElement.textContent = this.model.attributes.createdByName;
+
+ this.messageData['user'] = userElement;
+
+ if (relatedParentId && relatedParentType) {
+ const relatedParentElement = document.createElement('a');
+ relatedParentElement.href = `#${relatedParentType}/view/${relatedParentId}`;
+ relatedParentElement.dataset.id = relatedParentId;
+ relatedParentElement.dataset.scope = relatedParentType;
+ relatedParentElement.textContent = data.entityName || relatedParentType;
+
+ this.messageData['entityType'] = this.translateEntityType(relatedParentType);
+ this.messageData['entity'] = relatedParentElement;
+
+ this.messageName = 'userPostInParentReaction';
+ }
+
+ let postLabel = this.getLanguage().translateOption('Post', 'type', 'Note');
+
+ if (!this.toUpperCaseFirstLetter()) {
+ postLabel = Espo.Utils.lowerCaseFirst(postLabel);
+ }
+
+ const postElement = document.createElement('a');
+ postElement.href = `#Note/view/${this.model.attributes.relatedId}`;
+ postElement.dataset.id = this.model.attributes.relatedId;
+ postElement.dataset.scope = 'Note';
+ postElement.textContent = postLabel;
+
+ this.messageData['post'] = postElement;
+
+ this.createMessage();
+ }
+}
diff --git a/client/src/views/record/row-actions/default.js b/client/src/views/record/row-actions/default.js
index 6b7db04a16..39074aedd8 100644
--- a/client/src/views/record/row-actions/default.js
+++ b/client/src/views/record/row-actions/default.js
@@ -40,6 +40,8 @@ import View from 'view';
* groupIndex?: number,
* name?: string,
* text?: string,
+ * html?: string,
+ * viewKey?: string,
* }} module:views/record/row-actions/actions~item
*/
@@ -62,7 +64,13 @@ class DefaultRowActionsView extends View {
this.setupAdditionalActions();
- this.listenTo(this.model, 'change', () => this.reRender());
+ this.listenTo(this.model, 'change', (m, /** Record */o) => {
+ if (o.keepRowActions) {
+ return;
+ }
+
+ this.reRender();
+ });
}
afterRender() {
diff --git a/client/src/views/settings/fields/available-reactions.js b/client/src/views/settings/fields/available-reactions.js
new file mode 100644
index 0000000000..dfbe57a73d
--- /dev/null
+++ b/client/src/views/settings/fields/available-reactions.js
@@ -0,0 +1,129 @@
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU Affero General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+import ArrayFieldView from 'views/fields/array';
+import ReactionsHelper from 'helpers/misc/reactions';
+
+// noinspection JSUnusedGlobalSymbols
+export default class extends ArrayFieldView {
+
+ /**
+ * @type {Object.}
+ * @private
+ */
+ iconClassMap
+
+ /**
+ * @private
+ * @type {ReactionsHelper}
+ */
+ reactionsHelper
+
+ setup() {
+ this.reactionsHelper = new ReactionsHelper(this.getConfig(), this.getMetadata());
+
+ this.iconClassMap = this.reactionsHelper.getDefinitionList().reduce((o, it) => {
+ return {
+ [it.type]: it.iconClass,
+ ...o,
+ };
+ }, {});
+
+ super.setup();
+ }
+
+ setupOptions() {
+ const list = this.reactionsHelper.getDefinitionList();
+
+ this.params.options = list.map(it => it.type);
+
+ this.translatedOptions = list.reduce((o, it) => {
+ return {
+ [it.type]: this.translate(it.type, 'reactions'),
+ ...o
+ };
+ }, {});
+ }
+
+ /**
+ * @param {string} value
+ * @return {string}
+ */
+ getItemHtml(value) {
+ const html = super.getItemHtml(value);
+
+ const item = /** @type {HTMLElement} */
+ new DOMParser().parseFromString(html, 'text/html').body.childNodes[0];
+
+ const icon = this.createIconElement(value);
+
+ item.prepend(icon);
+
+ return item.outerHTML;
+ }
+
+ /**
+ * @private
+ * @param {string} value
+ * @return {HTMLSpanElement}
+ */
+ createIconElement(value) {
+ const icon = document.createElement('span');
+
+ (this.iconClassMap[value] || '')
+ .split(' ')
+ .filter(it => it)
+ .forEach(it => icon.classList.add(it));
+
+ icon.classList.add('text-soft');
+ icon.style.display = 'inline-block';
+ icon.style.width = 'var(--24px)';
+
+ return icon;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ async actionAddItem() {
+ const view = await super.actionAddItem();
+
+ view.whenRendered().then(() => {
+ const anchors = /** @type {HTMLAnchorElement[]} */
+ view.element.querySelectorAll(`a[data-value]`);
+
+ anchors.forEach(a => {
+ const icon = this.createIconElement(a.dataset.value);
+
+ a.prepend(icon);
+ });
+ });
+
+ return view;
+ }
+}
diff --git a/client/src/views/stream/note.js b/client/src/views/stream/note.js
index 9b24cfa25f..abce69469e 100644
--- a/client/src/views/stream/note.js
+++ b/client/src/views/stream/note.js
@@ -123,6 +123,7 @@ class NoteStreamView extends View {
listType: this.listType,
isThis: this.isThis,
parentModel: this.parentModel,
+ isNotification: this.options.isNotification,
});
}
}
diff --git a/client/src/views/stream/notes/post.js b/client/src/views/stream/notes/post.js
index 2080cf1fa5..7ace21d6ee 100644
--- a/client/src/views/stream/notes/post.js
+++ b/client/src/views/stream/notes/post.js
@@ -27,6 +27,7 @@
************************************************************************/
import NoteStreamView from 'views/stream/note';
+import NoteReactionsView from 'views/stream/reactions';
class PostNoteStreamView extends NoteStreamView {
@@ -49,6 +50,9 @@ class PostNoteStreamView extends NoteStreamView {
}
setup() {
+ this.addActionHandler('react', (e, target) => this.react(target.dataset.type));
+ this.addActionHandler('unReact', (e, target) => this.unReact(target.dataset.type));
+
this.createField('post', null, null, 'views/stream/fields/post');
this.createField('attachments', 'attachmentMultiple', {}, 'views/stream/fields/attachment-multiple', {
@@ -57,6 +61,8 @@ class PostNoteStreamView extends NoteStreamView {
this.isInternal = this.model.get('isInternal');
+ this.setupReactions();
+
if (!this.model.get('post') && this.model.get('parentId')) {
this.messageName = 'attach';
@@ -213,6 +219,64 @@ class PostNoteStreamView extends NoteStreamView {
this.createMessage();
}
+
+ /**
+ * @private
+ * @param {string} type
+ */
+ async react(type) {
+ Espo.Ui.notify(' ... ');
+
+ const previousMyReactions = this.model.attributes.myReactions;
+
+ this.model.set({myReactions: [type]}, {userReaction: true});
+
+ try {
+ await Espo.Ajax.postRequest(`Note/${this.model.id}/myReactions/${type}`);
+ } catch (e) {
+ this.model.set({myReactions: previousMyReactions}, {userReaction: true});
+
+ return;
+ }
+
+ Espo.Ui.success(this.translate('Reacted') + ' · ' + this.translate(type, 'reactions'));
+
+ await this.model.fetch({userReaction: true, keepRowActions: true});
+ }
+
+ /**
+ * @private
+ * @param {string} type
+ */
+ async unReact(type) {
+ Espo.Ui.notify(' ... ');
+
+ const previousMyReactions = this.model.attributes.myReactions;
+
+ this.model.set({myReactions: []}, {userReaction: true});
+
+ try {
+ await Espo.Ajax.deleteRequest(`Note/${this.model.id}/myReactions/${type}`);
+ } catch (e) {
+ this.model.set({myReactions: previousMyReactions}, {userReaction: true});
+
+ return;
+ }
+
+ Espo.Ui.warning(this.translate('Reaction Removed'));
+
+ await this.model.fetch({userReaction: true, keepRowActions: true});
+ }
+
+ /**
+ * @private
+ */
+ setupReactions() {
+ const view = new NoteReactionsView({model: this.model});
+ this.assignView('reactions', view, '.reactions-container');
+
+ this.listenTo(this.model, 'change:reactionCounts change:myReactions', () => view.reRenderWhenNoPopover());
+ }
}
export default PostNoteStreamView;
diff --git a/client/src/views/stream/panel.js b/client/src/views/stream/panel.js
index 4f9b101649..40748e7c29 100644
--- a/client/src/views/stream/panel.js
+++ b/client/src/views/stream/panel.js
@@ -338,7 +338,7 @@ class PanelStreamView extends RelationshipPanelView {
const model = this.collection.get(data.noteId);
if (model) {
- model.fetch()
+ model.fetch({keepRowActions: true})
.then(() => this.syncPinnedModel(model, true));
}
@@ -463,6 +463,13 @@ class PanelStreamView extends RelationshipPanelView {
}, view => {
view.render();
+ this.listenTo(this.pinnedCollection, 'change',
+ (/** import('model').default */model, /** Record */o) => {
+ if (o.userReaction) {
+ this.syncPinnedModel(model, false);
+ }
+ });
+
this.listenTo(view, 'after:save', /** import('model').default */model => {
this.syncPinnedModel(model, false);
});
@@ -493,6 +500,13 @@ class PanelStreamView extends RelationshipPanelView {
this.syncPinnedModel(model, true);
});
+ this.listenTo(this.collection, 'change',
+ (/** import('model').default */model, /** Record */o) => {
+ if (o.userReaction) {
+ this.syncPinnedModel(model, true);
+ }
+ });
+
this.listenTo(view, 'quote-reply', /** string */quoted => this.quoteReply(quoted));
}
});
@@ -619,6 +633,8 @@ class PanelStreamView extends RelationshipPanelView {
attachmentsNames: model.attributes.attachmentsNames,
attachmentsTypes: model.attributes.attachmentsTypes,
data: model.attributes.data,
+ reactionCounts: model.attributes.reactionCounts,
+ myReactions: model.attributes.myReactions,
});
}
diff --git a/client/src/views/stream/reactions.js b/client/src/views/stream/reactions.js
new file mode 100644
index 0000000000..089f31b981
--- /dev/null
+++ b/client/src/views/stream/reactions.js
@@ -0,0 +1,202 @@
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU Affero General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+import View from 'view';
+import Collection from 'collection';
+import ListRecordView from 'views/record/list';
+import ReactionsHelper from 'helpers/misc/reactions';
+
+export default class NoteReactionsView extends View {
+
+ // language=Handlebars
+ templateContent = `
+ {{#each dataList}}
+
+
+ {{count}}
+
+ {{/each}}
+ `
+
+ /**
+ * @private
+ * @type {string[]}
+ */
+ availableReactions
+
+ /**
+ * @type {Object.}
+ * @private
+ */
+ iconClassMap
+
+ /**
+ * @private
+ * @type {{destroy: function(), show: function()}}
+ */
+ popover
+
+ /**
+ * @param {{
+ * model: import('model').default,
+ * }} options
+ */
+ constructor(options) {
+ super(options);
+ }
+
+ data() {
+ /** @type {Record.} */
+ const counts = this.model.attributes.reactionCounts || {};
+ /** @type {string[]} */
+ const myReactions = this.model.attributes.myReactions || [];
+
+ return {
+ dataList: this.availableReactions
+ .filter(type => counts[type])
+ .map(type => {
+ return {
+ type: type,
+ count: counts[type].toString(),
+ label: this.translate('Reactions') + ' · ' + this.translate(type, 'reactions'),
+ iconClass: this.iconClassMap[type],
+ reacted: myReactions.includes(type),
+ };
+ }),
+ }
+ }
+
+ setup() {
+ const reactionsHelper = new ReactionsHelper(this.getConfig(), this.getMetadata());
+
+ this.availableReactions = reactionsHelper.getAvailableReactions();
+
+ const list = reactionsHelper.getDefinitionList();
+
+ this.iconClassMap = list.reduce((o, it) => {
+ return {[it.type]: it.iconClass, ...o};
+ }, {});
+
+ this.addHandler('click', 'a.reaction-count', (e, target) => this.showUsers(target.dataset.type));
+ }
+
+ /**
+ * @private
+ * @param {string} type
+ */
+ async showUsers(type) {
+ const a = this.element.querySelector(`a.reaction-count[data-type="${type}"]`);
+
+ /*if (this.popover) {
+ this.popover.destroy();
+ }*/
+
+ const popover = Espo.Ui.popover(a, {
+ placement: 'bottom',
+ content: `
+
`;
+ }
+
+ list.push({
+ action: reacted ? 'unReact' : 'react',
+ html: html,
+ data: {
+ id: this.model.id,
+ type: type,
+ },
+ groupIndex: 3,
+ });
+ });
+
return list;
}
}
diff --git a/client/src/views/stream/record/row-actions/reactions/reactions.js b/client/src/views/stream/record/row-actions/reactions/reactions.js
new file mode 100644
index 0000000000..951d6862fa
--- /dev/null
+++ b/client/src/views/stream/record/row-actions/reactions/reactions.js
@@ -0,0 +1,72 @@
+/************************************************************************
+ * This file is part of EspoCRM.
+ *
+ * EspoCRM – Open Source CRM application.
+ * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
+ * Website: https://www.espocrm.com
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * The interactive user interfaces in modified source and object code versions
+ * of this program must display Appropriate Legal Notices, as required under
+ * Section 5 of the GNU Affero General Public License version 3.
+ *
+ * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
+ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
+ ************************************************************************/
+
+import View from 'view';
+
+export default class ReactionsRowActionView extends View {
+
+ // language=Handlebars
+ templateContent = `
+