[REF] mail, im_livechat: move typing to discuss WIP

closes odoo/odoo#120851

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Sébastien Theys
2023-05-11 12:01:43 +02:00
parent 7a10c2756b
commit 7e0c68bf2b
40 changed files with 453 additions and 287 deletions
@@ -3,7 +3,7 @@
<t t-name="im_livechat.ThreadIcon" t-inherit="mail.ThreadIcon" t-inherit-mode="extension" owl="1">
<xpath expr="//*[contains(@class, 'o-mail-ThreadIcon')]" position="inside">
<t t-if="props.thread.type === 'livechat'">
<Typing t-if="props.thread.hasTypingMembers" channel="props.thread" size="props.size" displayText="false"/>
<Typing t-if="typingService.hasTypingMembers(props.thread)" channel="props.thread" size="props.size" displayText="false"/>
<div t-else="" class="fa fa-fw fa-comments" title="Livechat"/>
</t>
</xpath>
@@ -81,7 +81,7 @@ QUnit.test('Receives visitor typing status "is typing"', async (assert) => {
});
const { env, openDiscuss } = await start();
await openDiscuss(channelId);
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
const channel = pyEnv["discuss.channel"].searchRead([["id", "=", channelId]])[0];
// simulate receive typing notification from livechat visitor "is typing"
await afterNextRender(() =>
@@ -91,7 +91,7 @@ QUnit.test('Receives visitor typing status "is typing"', async (assert) => {
uuid: channel.uuid,
})
);
assert.containsOnce($, ".o-mail-Typing:contains(Visitor 20 is typing...)");
assert.containsOnce($, ".o-discuss-Typing:contains(Visitor 20 is typing...)");
});
QUnit.test('display canned response suggestions on typing ":"', async (assert) => {
@@ -27,9 +27,9 @@ QUnit.test("Public website visitor is typing", async (assert) => {
uuid: channel.uuid,
})
);
assert.containsOnce($, ".o-mail-Discuss-header .o-mail-Typing-icon");
assert.containsOnce($, ".o-mail-Discuss-header .o-discuss-Typing-icon");
assert.containsOnce(
$,
".o-mail-Discuss-header .o-mail-Typing-icon[title='Visitor 20 is typing...']"
".o-mail-Discuss-header .o-discuss-Typing-icon[title='Visitor 20 is typing...']"
);
});
+1 -2
View File
@@ -118,8 +118,7 @@ For more specific needs, you may also assign custom-defined actions
'application': True,
'assets': {
'web._assets_primary_variables': [
'mail/static/src/scss/variables/primary_variables.scss',
'mail/static/src/core_ui/primary_variables.scss',
'mail/static/src/**/primary_variables.scss',
],
'web.assets_backend': [
# depends on BS variables, can't be loaded in assets_primary or assets_secondary
@@ -28,21 +28,21 @@ export class AttachmentList extends Component {
}
/**
* @return {import('@mail/attachments/attachment_model').Attachment[]}
* @return {import("@mail/attachments/attachment_model").Attachment[]}
*/
get nonImagesAttachments() {
return this.props.attachments.filter((attachment) => !attachment.isImage);
}
/**
* @return {import('@mail/attachments/attachment_model').Attachment[]}
* @return {import("@mail/attachments/attachment_model").Attachment[]}
*/
get imagesAttachments() {
return this.props.attachments.filter((attachment) => attachment.isImage);
}
/**
* @param {import('@mail/attachments/attachment_model').Attachment} attachment
* @param {import("@mail/attachments/attachment_model").Attachment} attachment
*/
getImageUrl(attachment) {
const { imagesHeight } = this.props;
@@ -62,14 +62,14 @@ export class AttachmentList extends Component {
}
/**
* @param {import('@mail/attachments/attachment_model').Attachment} attachment
* @param {import("@mail/attachments/attachment_model").Attachment} attachment
*/
canDownload(attachment) {
return !attachment.uploading && !this.env.inComposer;
}
/**
* @param {import('@mail/attachments/attachment_model').Attachment} attachment
* @param {import("@mail/attachments/attachment_model").Attachment} attachment
*/
onClickDownload(attachment) {
const downloadLink = document.createElement("a");
@@ -82,7 +82,7 @@ export class AttachmentList extends Component {
}
/**
* @param {import('@mail/attachments/attachment_model').Attachment} attachment
* @param {import("@mail/attachments/attachment_model").Attachment} attachment
*/
onClickUnlink(attachment) {
if (this.env.inComposer) {
@@ -96,7 +96,7 @@ export class AttachmentList extends Component {
}
/**
* @param {import('@mail/attachments/attachment_model').Attachment} attachment
* @param {import("@mail/attachments/attachment_model").Attachment} attachment
*/
onConfirmUnlink(attachment) {
this.props.unlinkAttachment(attachment);
@@ -18,7 +18,7 @@ export class Attachment {
url;
/** @type {boolean} */
uploading;
/** @type {import('@mail/core/message_model').Message} */
/** @type {import("@mail/core/message_model").Message} */
message;
/** @type {import("@mail/core/thread_model").Thread} */
@@ -49,8 +49,8 @@ export class AttachmentService {
const threadData = Array.isArray(data.originThread)
? data.originThread[0][1]
: data.originThread;
// FIXME this prevents cyclic dependencies between mail.thread and mail.message
this.env.bus.trigger("MESSAGE-SERVICE:INSERT_THREAD", {
// this prevents cyclic dependencies between mail.thread and mail.attachment
this.env.bus.trigger("mail.thread/insert", {
model: threadData.model,
id: threadData.id,
});
+1 -46
View File
@@ -12,19 +12,13 @@ import { useEmojiPicker } from "../emoji_picker/emoji_picker";
import { sprintf } from "@web/core/utils/strings";
import { escapeAndCompactTextContent } from "../utils/format.js";
import { FileUploader } from "@web/views/fields/file_handler";
import { Typing } from "./typing";
import { NavigableList } from "@mail/composer/navigable_list";
import { useDebounced } from "@web/core/utils/timing";
import { useSuggestion } from "@mail/composer/suggestion_hook";
import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { MessageConfirmDialog } from "../core_ui/message_confirm_dialog";
export const SHORT_TYPING = 5000;
export const LONG_TYPING = 50000;
/**
* @typedef {Object} Props
* @property {import("@mail/composer/composer_model").Composer} composer
@@ -44,7 +38,6 @@ export class Composer extends Component {
static components = {
AttachmentList,
FileUploader,
Typing,
NavigableList,
};
static defaultProps = {
@@ -82,15 +75,10 @@ export class Composer extends Component {
this.threadService = useService("mail.thread");
this.ref = useRef("textarea");
this.fakeTextarea = useRef("fakeTextarea");
this.typingNotified = false;
this.state = useState({
autofocus: 0,
active: true,
});
this.stopTyping = useDebounced(() => {
this.notifyIsTyping(false);
this.typingNotified = false;
}, SHORT_TYPING);
this.selection = useSelection({
refName: "textarea",
model: this.props.composer.selection,
@@ -170,17 +158,6 @@ export class Composer extends Component {
});
}
onInput(ev) {
if (!this.typingNotified && ev.target.value) {
this.notifyIsTyping();
this.typingNotified = true;
browser.setTimeout(() => {
this.typingNotified = false;
}, LONG_TYPING);
}
this.stopTyping();
}
get placeholder() {
if (this.props.placeholder) {
return this.props.placeholder;
@@ -434,7 +411,7 @@ export class Composer extends Component {
}
async sendMessage() {
return this.processMessage(async (value) => {
await this.processMessage(async (value) => {
const thread =
this.props.messageToReplyTo?.message?.originThread ?? this.props.composer.thread;
const postData = {
@@ -454,31 +431,9 @@ export class Composer extends Component {
}
this.suggestion?.clearRawMentions();
this.props.messageToReplyTo?.cancel();
if (this.typingNotified) {
this.typingNotified = false;
this.notifyIsTyping(false);
}
});
}
/**
* Notify the server of the current typing status
*
* @param {boolean} [is_typing=true]
*/
notifyIsTyping(is_typing = true) {
if (["chat", "channel", "group"].includes(this.thread?.type)) {
this.messaging.rpc(
"/discuss/channel/notify_typing",
{
channel_id: this.thread.id,
is_typing,
},
{ silent: true }
);
}
}
async editMessage() {
if (this.ref.el.value || this.props.composer.message.attachments.length > 0) {
await this.processMessage(async (value) =>
@@ -40,7 +40,6 @@
t-ref="textarea"
style="height:40px;"
t-on-keydown="onKeydown"
t-on-input="onInput"
t-on-focusin="onFocusin"
t-on-focusout="() => this.props.composer.isFocused = false"
t-on-click="(ev) => markEventHandled(ev, 'composer.onClickTextarea')"
@@ -95,9 +94,6 @@
attachments="props.composer.attachments"
unlinkAttachment="attachmentUploader.unlink"
imagesHeight="50"/>
<div t-if="thread and thread.type !== 'chatter' and !compact">
<Typing channel="thread" size="'medium'"/>
</div>
</div>
</div>
<span t-if="props.composer.message" class="text-muted">escape to <a href="#" t-on-click="props.onDiscardCallback">cancel</a>, enter to <a href="#" t-on-click="editMessage">save</a></span>
@@ -5,7 +5,7 @@ import { useService } from "@web/core/utils/hooks";
export function useSuggestion() {
const comp = useComponent();
/** @type {import('@mail/composer/suggestion_service').SuggestionService} */
/** @type {import("@mail/composer/suggestion_service").SuggestionService} */
const suggestionService = useService("mail.suggestion");
const self = {
clearRawMentions() {
-52
View File
@@ -1,52 +0,0 @@
/* @odoo-module */
import { Component } from "@odoo/owl";
import { sprintf } from "@web/core/utils/strings";
import { useMessaging } from "../core/messaging_hook";
import { _t } from "@web/core/l10n/translation";
/**
* @typedef {Object} Props
* @property {number} channel_id
* @property {string} size
* @property {boolean} displayText
* @extends {Component<Props, Env>}
*/
export class Typing extends Component {
static defaultProps = {
size: "small",
displayText: true,
};
static props = ["channel", "size?", "displayText?"];
static template = "mail.Typing";
setup() {
this.messaging = useMessaging();
}
/** @returns {boolean|string} */
get text() {
if (this.props.channel.hasTypingMembers) {
const typingMemberNames = this.props.channel.typingMembers.map(({ persona }) =>
this.props.channel.getMemberName(persona)
);
if (typingMemberNames.length === 1) {
return sprintf(_t("%s is typing..."), typingMemberNames[0]);
}
if (typingMemberNames.length === 2) {
return sprintf(
_t("%s and %s are typing..."),
typingMemberNames[0],
typingMemberNames[1]
);
}
return sprintf(
_t("%s, %s and more are typing..."),
typingMemberNames[0],
typingMemberNames[1]
);
}
return false;
}
}
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.Typing" owl="1">
<div class="o-mail-Typing d-flex">
<t t-if="props.channel.hasTypingMembers">
<div class="o-mail-Typing-icon d-flex align-items-center" t-attf-class="{{ className }}" t-att-title="text">
<span class="o-mail-Typing-dot d-flex flex-shrink-0 rounded-pill bg-500" t-att-class="{
'o-sizeMedium': props.size === 'medium',
'o-sizeSmall': props.size === 'small',
}"/>
<span class="flex-grow-1 flex-shrink-0"/>
<span class="o-mail-Typing-dot o-mail-Typing-dot2 d-flex flex-shrink-0 rounded-pill bg-500" t-att-class="{
'o-sizeMedium': props.size === 'medium',
'o-sizeSmall': props.size === 'small',
}"/>
<span class="flex-grow-1 flex-shrink-0"/>
<span class="o-mail-Typing-dot o-mail-Typing-dot3 d-flex flex-shrink-0 rounded-pill bg-500" t-att-class="{
'o-sizeMedium': props.size === 'medium',
'o-sizeSmall': props.size === 'small',
}"/>
</div>
<t t-if="props.displayText">
<span class="ms-1"/>
<span class="text-truncate" t-out="text"/>
</t>
</t>
</div>
</t>
</templates>
@@ -10,10 +10,11 @@ import { createLocalId } from "../utils/misc";
* @property {number} threadId
*/
export class ChannelMember {
/** @type {number} */
id;
personaLocalId;
rtcSessionId;
threadId;
typingTimer;
/** @type {import("@mail/core/store_service").Store} */
_store;
@@ -6,10 +6,15 @@ import { ChannelMember } from "./channel_member_model";
export class ChannelMemberService {
constructor(env, { "mail.store": store, "mail.persona": personaService }) {
this.env = env;
this.store = store;
this.personaService = personaService;
}
/**
* @param {Object|Array} data
* @returns {ChannelMember}
*/
insert(data) {
const memberData = Array.isArray(data) ? data[1] : data;
let member = this.store.channelMembers[memberData.id];
@@ -34,10 +39,17 @@ export class ChannelMemberService {
});
}
member.threadId = memberData.threadId ?? member.threadId ?? memberData.channel.id;
if (!member.thread) {
// this prevents cyclic dependencies between mail.thread and discuss.channel.member
this.env.bus.trigger("mail.thread/insert", {
id: member.threadId,
model: "discuss.channel",
});
}
switch (command) {
case "insert":
{
if (!member.thread?.channelMembers?.includes(member)) {
if (!member.thread.channelMembers.includes(member)) {
member.thread.channelMembers.push(member);
}
}
@@ -207,8 +207,8 @@ export class MessageService {
insert(data) {
let message;
if (data.res_id) {
// FIXME this prevents cyclic dependencies between mail.thread and mail.message
this.env.bus.trigger("MESSAGE-SERVICE:INSERT_THREAD", {
// this prevents cyclic dependencies between mail.thread and mail.message
this.env.bus.trigger("mail.thread/insert", {
model: data.model,
id: data.res_id,
});
@@ -6,14 +6,11 @@ import { cleanTerm } from "@mail/utils/format";
import { removeFromArray, removeFromArrayWithPredicate } from "@mail/utils/arrays";
import { LinkPreview } from "./link_preview_model";
import { CannedResponse } from "./canned_response_model";
import { browser } from "@web/core/browser/browser";
import { sprintf } from "@web/core/utils/strings";
import { _t } from "@web/core/l10n/translation";
import { createLocalId } from "../utils/misc";
import { registry } from "@web/core/registry";
export const OTHER_LONG_TYPING = 60000;
/**
* @typedef {Messaging} Messaging
*/
@@ -409,34 +406,6 @@ export class Messaging {
}
break;
}
case "discuss.channel.member/typing_status": {
const isTyping = notif.payload.isTyping;
const channel =
this.store.threads[
createLocalId("discuss.channel", notif.payload.channel.id)
];
if (!channel) {
return;
}
const member = this.channelMemberService.insert(notif.payload);
if (member.persona === this.store.self) {
return;
}
if (isTyping) {
if (!channel.typingMembers.includes(member)) {
channel.typingMemberIds.push(member.id);
}
if (member.typingTimer) {
browser.clearTimeout(member.typingTimer);
}
member.typingTimer = browser.setTimeout(() => {
removeFromArray(channel.typingMemberIds, member.id);
}, OTHER_LONG_TYPING);
} else {
removeFromArray(channel.typingMemberIds, member.id);
}
break;
}
case "discuss.channel/unpin": {
const thread =
this.store.threads[createLocalId("discuss.channel", notif.payload.id)];
@@ -500,7 +469,7 @@ export class Messaging {
async _handleNotificationNewMessage(notif) {
const { id, message: messageData } = notif.payload;
let channel = this.store.threads[createLocalId("discuss.channel", id)];
if (!channel) {
if (!channel || !channel.type) {
const [channelData] = await this.orm.call("discuss.channel", "channel_info", [id]);
channel = this.threadService.insert({
id: channelData.id,
+1 -18
View File
@@ -14,7 +14,7 @@ import { Deferred } from "@web/core/utils/concurrency";
* @property {{id: number}} partner
* @typedef SuggestedRecipient
* @property {string} email
* @property {import('@mail/core/persona_model').Persona|false} persona
* @property {import("@mail/core/persona_model").Persona|false} persona
* @property {string} lang
* @property {string} reason
* @property {boolean} checked
@@ -110,7 +110,6 @@ export class Thread {
/** @type {ScrollPosition} */
scrollPosition = new ScrollPosition();
showOnlyVideo = false;
typingMemberIds = [];
/** @type {import("@mail/core/store_service").Store} */
_store;
/** @type {string} */
@@ -133,14 +132,6 @@ export class Thread {
type: data.type,
_store: store,
});
if (this.type === "channel") {
this._store.discuss.channels.threads.push(this.localId);
} else if (this.type === "chat" || this.type === "group") {
this._store.discuss.chats.threads.push(this.localId);
}
if (!this.type && !["mail.box", "discuss.channel"].includes(this.model)) {
this.type = "chatter";
}
store.threads[this.localId] = this;
}
@@ -409,10 +400,6 @@ export class Thread {
return this.memberCount - this.channelMembers.length;
}
get hasTypingMembers() {
return this.typingMembers.length !== 0;
}
get rtcInvitingSession() {
return this._store.rtcSessions[this.invitingRtcSessionId];
}
@@ -421,10 +408,6 @@ export class Thread {
return this.needactionMessages.length > 0;
}
get typingMembers() {
return this.typingMemberIds.map((memberId) => this._store.channelMembers[memberId]);
}
get videoCount() {
return Object.values(this.rtcSessions).filter((session) => session.videoStream).length;
}
+17 -3
View File
@@ -43,8 +43,8 @@ export class ThreadService {
this.personaService = services["mail.persona"];
/** @type {import("@mail/core/message_service").MessageService} */
this.messageService = services["mail.message"];
// FIXME this prevents cyclic dependencies between mail.thread and mail.message
this.env.bus.addEventListener("MESSAGE-SERVICE:INSERT_THREAD", ({ detail }) => {
// this prevents cyclic dependencies between mail.thread and other services
this.env.bus.addEventListener("mail.thread/insert", ({ detail }) => {
const model = detail.model;
const id = detail.id;
const type = detail.type;
@@ -812,6 +812,20 @@ export class ThreadService {
);
}
}
if (
thread.type === "channel" &&
!this.store.discuss.channels.threads.includes(thread.localId)
) {
this.store.discuss.channels.threads.push(thread.localId);
} else if (
(thread.type === "chat" || thread.type === "group") &&
!this.store.discuss.chats.threads.includes(thread.localId)
) {
this.store.discuss.chats.threads.push(thread.localId);
}
if (!thread.type && !["mail.box", "discuss.channel"].includes(thread.model)) {
thread.type = "chatter";
}
}
/**
@@ -1046,7 +1060,7 @@ export class ThreadService {
}
/**
* @param {import('@mail/core/persona_model').Persona} persona
* @param {import("@mail/core/persona_model").Persona} persona
* @param {import("@mail/core/thread_model").Thread} [thread]
*/
avatarUrl(persona, thread) {
@@ -15,7 +15,7 @@ export class MessageInReply extends Component {
this.messaging = useMessaging();
this.store = useStore();
this.user = useService("user");
/** @type {import('@mail/core/thread_service').ThreadService} */
/** @type {import("@mail/core/thread_service").ThreadService} */
this.threadService = useService("mail.thread");
}
@@ -20,7 +20,7 @@ export class MessageReactionMenu extends Component {
static template = "mail.MessageReactionMenu";
setup() {
/** @type {import('@mail/core/thread_service').ThreadService} */
/** @type {import("@mail/core/thread_service").ThreadService} */
this.threadService = useService("mail.thread");
this.root = useRef("root");
this.store = useStore();
@@ -9,9 +9,6 @@ $o-mail-LinkPreview-width: 320px !default;
$o-mail-LinkPreview-height: 240px !default;
$o-mail-LinkPreviewCard-height: 80px !default;
$o-mail-Typing-medium: 5px !default;
$o-mail-Typing-small: 3px !default;
$o-mail-Message-sidebarWidth: 48px !default;
$o-mail-NavigableList-zIndex: 11;
$o-mail-Chatter-minWidth: 530px !default;
@@ -1,29 +0,0 @@
/* @odoo-module */
import { Composer } from "@mail/composer/composer";
import { registry } from "@web/core/registry";
import { patch } from "@web/core/utils/patch";
const commandRegistry = registry.category("discuss.channel_commands");
patch(Composer.prototype, "discuss", {
/**
* @override
*/
onInput(ev) {
if (this.thread?.model === "discuss.channel" && ev.target.value.startsWith("/")) {
const [firstWord] = ev.target.value.substring(1).split(/\s/);
const command = commandRegistry.get(firstWord, false);
if (
ev.target.value === "/" || // suggestions not yet started
this.hasSuggestions ||
(command &&
(!command.channel_types || command.channel_types.includes(this.thread.type)))
) {
this.stopTyping();
return;
}
}
this._super(ev);
},
});
@@ -0,0 +1,82 @@
/* @odoo-module */
import { Composer } from "@mail/composer/composer";
import { Typing } from "@mail/discuss/typing/typing";
import { browser } from "@web/core/browser/browser";
import { patch } from "@web/core/utils/patch";
import { registry } from "@web/core/registry";
import { useDebounced } from "@web/core/utils/timing";
const commandRegistry = registry.category("discuss.channel_commands");
export const SHORT_TYPING = 5000;
export const LONG_TYPING = 50000;
patch(Composer, "discuss/typing", {
components: { ...Composer.components, Typing },
});
patch(Composer.prototype, "discuss/typing", {
/**
* @override
*/
setup() {
this._super();
this.typingNotified = false;
this.stopTypingDebounced = useDebounced(this.stopTyping.bind(this), SHORT_TYPING);
},
/**
* Notify the server of the current typing status
*
* @param {boolean} [is_typing=true]
*/
notifyIsTyping(is_typing = true) {
if (["chat", "channel", "group"].includes(this.thread?.type)) {
this.messaging.rpc(
"/discuss/channel/notify_typing",
{
channel_id: this.thread.id,
is_typing,
},
{ silent: true }
);
}
},
/**
* @param {InputEvent} ev
*/
onInput(ev) {
if (this.thread?.model === "discuss.channel" && ev.target.value.startsWith("/")) {
const [firstWord] = ev.target.value.substring(1).split(/\s/);
const command = commandRegistry.get(firstWord, false);
if (
ev.target.value === "/" || // suggestions not yet started
this.hasSuggestions ||
(command &&
(!command.channel_types || command.channel_types.includes(this.thread.type)))
) {
this.stopTyping();
return;
}
}
if (!this.typingNotified && ev.target.value) {
this.typingNotified = true;
this.notifyIsTyping();
browser.setTimeout(() => (this.typingNotified = false), LONG_TYPING);
}
this.stopTypingDebounced();
},
/**
* @override
*/
async sendMessage() {
await this._super();
this.stopTyping();
},
stopTyping() {
if (this.typingNotified) {
this.typingNotified = false;
this.notifyIsTyping(false);
}
},
});
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.Composer" t-inherit-mode="extension">
<xpath expr="//AttachmentList" position="after">
<div t-if="thread and thread.type !== 'chatter' and !compact">
<Typing channel="thread" size="'medium'"/>
</div>
</xpath>
<xpath expr="//*[hasclass('o-mail-Composer-input')]" position="attributes">
<attribute name="t-on-input">onInput</attribute>
</xpath>
</t>
</templates>
@@ -0,0 +1,2 @@
$o-discuss-Typing-medium: 5px !default;
$o-discuss-Typing-small: 3px !default;
@@ -0,0 +1,20 @@
/* @odoo-module */
import { ThreadIcon } from "@mail/discuss_app/thread_icon";
import { Typing } from "@mail/discuss/typing/typing";
import { useTypingService } from "@mail/discuss/typing/typing_service";
import { patch } from "@web/core/utils/patch";
patch(ThreadIcon, "discuss/typing", {
components: { ...ThreadIcon.components, Typing },
});
patch(ThreadIcon.prototype, "discuss/typing", {
/**
* @override
*/
setup() {
this._super();
this.typingService = useTypingService();
},
});
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.ThreadIcon" t-inherit-mode="extension">
<xpath expr="//t[@name='chat']" position="replace">
<Typing t-if="typingService.hasTypingMembers(props.thread)" channel="props.thread" size="props.size" displayText="false"/>
<t t-else="">$0</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,50 @@
/* @odoo-module */
import { useMessaging } from "@mail/core/messaging_hook";
import { useTypingService } from "@mail/discuss/typing/typing_service";
import { Component } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
/**
* @typedef {Object} Props
* @property {import("@mail/core/thread_model").Thread} channel
* @property {string} size
* @property {boolean} displayText
* @extends {Component<Props, Env>}
*/
export class Typing extends Component {
static defaultProps = {
size: "small",
displayText: true,
};
static props = ["channel", "size?", "displayText?"];
static template = "discuss.Typing";
setup() {
this.messaging = useMessaging();
this.typingService = useTypingService();
}
/** @returns {string} */
get text() {
const typingMemberNames = this.typingService
.getTypingMembers(this.props.channel)
.map(({ persona }) => this.props.channel.getMemberName(persona));
if (typingMemberNames.length === 1) {
return sprintf(_t("%s is typing..."), typingMemberNames[0]);
}
if (typingMemberNames.length === 2) {
return sprintf(
_t("%s and %s are typing..."),
typingMemberNames[0],
typingMemberNames[1]
);
}
return sprintf(
_t("%s, %s and more are typing..."),
typingMemberNames[0],
typingMemberNames[1]
);
}
}
@@ -1,21 +1,21 @@
.o-mail-Typing-dot {
.o-discuss-Typing-dot {
animation: o_mail_Typing_animation 1.5s linear infinite;
&.o-sizeMedium {
width: $o-mail-Typing-medium;
height: $o-mail-Typing-medium;
width: $o-discuss-Typing-medium;
height: $o-discuss-Typing-medium;
}
&.o-sizeSmall {
width: $o-mail-Typing-small;
height: $o-mail-Typing-small;
width: $o-discuss-Typing-small;
height: $o-discuss-Typing-small;
}
&.o-mail-Typing-dot2 {
&.o-discuss-Typing-dot2 {
animation-delay: -1.35s;
}
&.o-mail-Typing-dot3 {
&.o-discuss-Typing-dot3 {
animation-delay: -1.2s;
}
}
@@ -29,7 +29,7 @@
}
}
.o-mail-Typing:before {
.o-discuss-Typing:before {
// invisible character so that typing status bar has constant height, regardless of text content.
content: "\200b"; /* unicode zero width space character */
}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="discuss.Typing" owl="1">
<div class="o-discuss-Typing d-flex">
<t t-if="typingService.hasTypingMembers(props.channel)">
<div class="o-discuss-Typing-icon d-flex align-items-center" t-attf-class="{{ className }}" t-att-title="text">
<span class="o-discuss-Typing-dot d-flex flex-shrink-0 rounded-pill bg-500"
t-att-class="{
'o-sizeMedium': props.size === 'medium',
'o-sizeSmall': props.size === 'small',
}"
/>
<span class="flex-grow-1 flex-shrink-0"/>
<span class="o-discuss-Typing-dot o-discuss-Typing-dot2 d-flex flex-shrink-0 rounded-pill bg-500"
t-att-class="{
'o-sizeMedium': props.size === 'medium',
'o-sizeSmall': props.size === 'small',
}"
/>
<span class="flex-grow-1 flex-shrink-0"/>
<span class="o-discuss-Typing-dot o-discuss-Typing-dot3 d-flex flex-shrink-0 rounded-pill bg-500"
t-att-class="{
'o-sizeMedium': props.size === 'medium',
'o-sizeSmall': props.size === 'small',
}"
/>
</div>
<t t-if="props.displayText">
<span class="ms-1"/>
<span class="text-truncate" t-out="text"/>
</t>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,130 @@
/* @odoo-module */
import { reactive, useState } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export const OTHER_LONG_TYPING = 60000;
/**
* @typedef TypingState
* @property {Map<number, Set<number>>} memberIdsByChannelId
* @property {Map<number, number>} timerByMemberId
*/
export class Typing {
busService;
/** @type {import("@mail/core/channel_member_service").ChannelMemberService} */
channelMemberService;
/** @type {TypingState} */
state = reactive({
memberIdsByChannelId: new Map(),
timerByMemberId: new Map(),
});
/** @type {import("@mail/core/store_service").Store} */
storeService;
constructor({
bus_service: busService,
"discuss.channel.member": channelMemberService,
"mail.store": storeService,
}) {
Object.assign(this, { busService, channelMemberService, storeService });
this.setup();
}
setup() {
this.busService.subscribe("discuss.channel.member/typing_status", (payload) => {
const member = this.channelMemberService.insert(payload);
if (payload.isTyping) {
this.addTypingMember(member);
} else {
this.removeTypingMember(member);
}
});
this.busService.start();
}
/**
* @param {import("@mail/core/channel_member_model").ChannelMember} member
*/
addTypingMember(member) {
if (!this.state.memberIdsByChannelId.has(member.thread.id)) {
this.state.memberIdsByChannelId.set(member.thread.id, new Set());
}
const memberIds = this.state.memberIdsByChannelId.get(member.thread.id);
memberIds.add(member.id);
browser.clearTimeout(this.state.timerByMemberId.get(member.id));
this.state.timerByMemberId.set(
member.id,
browser.setTimeout(() => this.removeTypingMember(member), OTHER_LONG_TYPING)
);
}
/**
* @param {TypingState} state
* @param {import("@mail/core/thread_model").Thread} channel
* @returns {import("@mail/core/channel_member_model").ChannelMember[]}
*/
getTypingMembers(state, channel) {
return [...(state.memberIdsByChannelId.get(channel.id) ?? new Set())]
.map((id) => this.channelMemberService.insert({ id }))
.filter((member) => member.persona !== this.storeService.self);
}
/**
* @param {TypingState} state
* @param {import("@mail/core/thread_model").Thread} channel
* @returns {boolean}
*/
hasTypingMembers(state, channel) {
return this.getTypingMembers(state, channel).length > 0;
}
/**
* @param {import("@mail/core/channel_member_model").ChannelMember} member
*/
removeTypingMember(member) {
const memberIds = this.state.memberIdsByChannelId.get(member.thread.id);
if (memberIds) {
memberIds.delete(member.id);
if (memberIds.size === 0) {
this.state.memberIdsByChannelId.delete(member.thread.id);
}
}
browser.clearTimeout(this.state.timerByMemberId.get(member.id));
this.state.timerByMemberId.delete(member.id);
}
}
export const discussTypingService = {
dependencies: ["bus_service", "discuss.channel.member", "mail.store"],
start(env, services) {
return new Typing(services);
},
};
registry.category("services").add("discuss.typing", discussTypingService);
// wrapper to ensure service getters receive the reactive state of the component
export function useTypingService() {
/** @type {Typing} */
const typingService = useService("discuss.typing");
const state = useState(typingService.state);
return {
/**
* @param {import("@mail/core/thread_model").Thread} channel
* @returns {import("@mail/core/channel_member_model").ChannelMember[]}
*/
getTypingMembers(channel) {
return typingService.getTypingMembers(state, channel);
},
/**
* @param {import("@mail/core/thread_model").Thread} channel
* @returns {boolean}
*/
hasTypingMembers(channel) {
return typingService.hasTypingMembers(state, channel);
},
};
}
@@ -1,9 +1,8 @@
/* @odoo-module */
import { Component } from "@odoo/owl";
import { useStore } from "@mail/core/messaging_hook";
import { Typing } from "@mail/composer/typing";
import { createLocalId } from "@mail/utils/misc";
import { Component } from "@odoo/owl";
/**
* @typedef {Object} Props
@@ -14,7 +13,6 @@ import { createLocalId } from "@mail/utils/misc";
*/
export class ThreadIcon extends Component {
static template = "mail.ThreadIcon";
static components = { Typing };
static props = ["thread", "size?", "className?"];
static defaultProps = {
size: "medium",
@@ -8,8 +8,7 @@
<div t-if="!props.thread.authorizedGroupFullName" class="fa fa-fw fa-globe" title="Public Channel"/>
</t>
<t t-elif="props.thread.type === 'chat' and chatPartner">
<Typing t-if="props.thread.hasTypingMembers" channel="props.thread" size="props.size" displayText="false"/>
<t t-else="">
<t name="chat">
<t name="chat_static">
<div t-if="chatPartner.im_status === 'online'" class="fa fa-fw fa-circle text-success" title="Online"/>
<div t-elif="chatPartner.im_status === 'offline'" class="fa fa-fw fa-circle-o" title="Offline"/>
@@ -14,7 +14,7 @@ export class DiscussPublic extends Component {
setup() {
this.messaging = useMessaging();
/** @type {import('@mail/core/thread_service').ThreadService} */
/** @type {import("@mail/core/thread_service").ThreadService} */
this.threadService = useService("mail.thread");
this.rtc = useService("mail.rtc");
this.store = useStore();
@@ -13,7 +13,7 @@ export class WelcomePage extends Component {
this.messaging = useMessaging();
this.store = useStore();
this.rpc = useService("rpc");
/** @type {import('@mail/core/persona_service').PersonaService} */
/** @type {import("@mail/core/persona_service").PersonaService} */
this.personaService = useService("mail.persona");
this.state = useState({
userName: "Guest",
@@ -1,7 +1,5 @@
$o-mail-Avatar-size: 36px !default;
$o-mail-ChatWindow-width: 340px !default;
$o-mail-Typing-medium: 5px !default;
$o-mail-Typing-small: 3px !default;
$o-mail-Chatter-mobile-gap: 2% !default;
$o-mail-sidebar-icon-opacity: 0.7 !default;
$o-mail-Discuss-inspector: 250px !default;
@@ -26,9 +26,9 @@ export class MessagingMenu extends Component {
this.store = useStore();
this.hasTouch = hasTouch;
this.notification = useState(useService("mail.notification.permission"));
/** @type {import('@mail/web/chat_window/chat_window_service').ChatWindowService} */
/** @type {import("@mail/web/chat_window/chat_window_service").ChatWindowService} */
this.chatWindowService = useState(useService("mail.chat_window"));
/** @type {import('@mail/core/thread_service').ThreadService} */
/** @type {import("@mail/core/thread_service").ThreadService} */
this.threadService = useState(useService("mail.thread"));
this.action = useService("action");
this.state = useState({
@@ -1,6 +1,7 @@
/** @odoo-module **/
/* @odoo-module */
import { nextTick } from "@web/../tests/helpers/utils";
import { LONG_TYPING, SHORT_TYPING } from "@mail/discuss/typing/composer_patch";
import { OTHER_LONG_TYPING } from "@mail/discuss/typing/typing_service";
import {
afterNextRender,
insertText,
@@ -9,9 +10,7 @@ import {
startServer,
} from "@mail/../tests/helpers/test_utils";
import { Command } from "@mail/../tests/helpers/command";
import { LONG_TYPING, SHORT_TYPING } from "@mail/composer/composer";
import { OTHER_LONG_TYPING } from "@mail/core/messaging_service";
import { nextTick } from "@web/../tests/helpers/utils";
QUnit.module("typing");
@@ -27,7 +26,7 @@ QUnit.test('receive other member typing status "is typing"', async (assert) => {
});
const { env, openDiscuss } = await start();
await openDiscuss(channelId);
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
// simulate receive typing notification from demo
await afterNextRender(() =>
@@ -37,7 +36,7 @@ QUnit.test('receive other member typing status "is typing"', async (assert) => {
is_typing: true,
})
);
assert.strictEqual($(".o-mail-Typing").text(), "Demo is typing...");
assert.strictEqual($(".o-discuss-Typing").text(), "Demo is typing...");
});
QUnit.test(
@@ -54,7 +53,7 @@ QUnit.test(
});
const { env, openDiscuss } = await start();
await openDiscuss(channelId);
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
// simulate receive typing notification from demo "is typing"
await afterNextRender(() =>
@@ -64,7 +63,7 @@ QUnit.test(
is_typing: true,
})
);
assert.strictEqual($(".o-mail-Typing").text(), "Demo is typing...");
assert.strictEqual($(".o-discuss-Typing").text(), "Demo is typing...");
// simulate receive typing notification from demo "is no longer typing"
await afterNextRender(() =>
@@ -74,7 +73,7 @@ QUnit.test(
is_typing: false,
})
);
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
}
);
@@ -93,7 +92,7 @@ QUnit.test(
const { advanceTime, env, openDiscuss } = await start({ hasTimeControl: true });
await openDiscuss(channelId);
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
// simulate receive typing notification from demo "is typing"
await afterNextRender(() =>
@@ -103,10 +102,10 @@ QUnit.test(
is_typing: true,
})
);
assert.strictEqual($(".o-mail-Typing").text(), "Demo is typing...");
assert.strictEqual($(".o-discuss-Typing").text(), "Demo is typing...");
await afterNextRender(() => advanceTime(OTHER_LONG_TYPING));
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
}
);
@@ -124,7 +123,7 @@ QUnit.test(
});
const { advanceTime, env, openDiscuss } = await start({ hasTimeControl: true });
await openDiscuss(channelId);
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
// simulate receive typing notification from demo "is typing"
await afterNextRender(() =>
@@ -136,7 +135,7 @@ QUnit.test(
is_typing: true,
})
);
assert.strictEqual($(".o-mail-Typing").text(), "Demo is typing...");
assert.strictEqual($(".o-discuss-Typing").text(), "Demo is typing...");
// simulate receive typing notification from demo "is typing" again after long time.
await advanceTime(LONG_TYPING);
@@ -148,9 +147,9 @@ QUnit.test(
await nextTick();
await advanceTime(LONG_TYPING);
await nextAnimationFrame();
assert.strictEqual($(".o-mail-Typing").text(), "Demo is typing...");
assert.strictEqual($(".o-discuss-Typing").text(), "Demo is typing...");
await afterNextRender(() => advanceTime(OTHER_LONG_TYPING - LONG_TYPING));
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
}
);
@@ -172,7 +171,7 @@ QUnit.test('receive several other members typing status "is typing"', async (ass
});
const { env, openDiscuss } = await start();
await openDiscuss(channelId);
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
// simulate receive typing notification from other 10 (is typing)
await afterNextRender(() =>
@@ -182,7 +181,7 @@ QUnit.test('receive several other members typing status "is typing"', async (ass
is_typing: true,
})
);
assert.strictEqual($(".o-mail-Typing").text(), "Other 10 is typing...");
assert.strictEqual($(".o-discuss-Typing").text(), "Other 10 is typing...");
// simulate receive typing notification from other 11 (is typing)
await afterNextRender(() =>
@@ -193,7 +192,7 @@ QUnit.test('receive several other members typing status "is typing"', async (ass
})
);
assert.strictEqual(
$(".o-mail-Typing").text(),
$(".o-discuss-Typing").text(),
"Other 10 and Other 11 are typing...",
"Should display longer typer named first"
);
@@ -206,7 +205,7 @@ QUnit.test('receive several other members typing status "is typing"', async (ass
is_typing: true,
})
);
assert.strictEqual($(".o-mail-Typing").text(), "Other 10, Other 11 and more are typing...");
assert.strictEqual($(".o-discuss-Typing").text(), "Other 10, Other 11 and more are typing...");
// simulate receive typing notification from other 10 (no longer is typing)
await afterNextRender(() =>
@@ -216,7 +215,7 @@ QUnit.test('receive several other members typing status "is typing"', async (ass
is_typing: false,
})
);
assert.strictEqual($(".o-mail-Typing").text(), "Other 11 and Other 12 are typing...");
assert.strictEqual($(".o-discuss-Typing").text(), "Other 11 and Other 12 are typing...");
// simulate receive typing notification from other 10 (is typing again)
await afterNextRender(() =>
@@ -227,7 +226,7 @@ QUnit.test('receive several other members typing status "is typing"', async (ass
})
);
assert.strictEqual(
$(".o-mail-Typing").text(),
$(".o-discuss-Typing").text(),
"Other 11, Other 12 and more are typing...",
"Should order by longer typer ('Other 10' just recently restarted typing)"
);
@@ -317,7 +316,7 @@ QUnit.test(
assert.verifySteps(["notify_typing:true"]);
await nextAnimationFrame();
assert.strictEqual($(".o-mail-Typing").text(), "");
assert.strictEqual($(".o-discuss-Typing").text(), "");
}
);
@@ -347,8 +346,8 @@ QUnit.test("chat: correspondent is typing", async (assert) => {
is_typing: true,
})
);
assert.containsOnce($, ".o-mail-Typing-icon");
assert.strictEqual($(".o-mail-Typing-icon")[0].title, "Demo is typing...");
assert.containsOnce($, ".o-discuss-Typing-icon");
assert.strictEqual($(".o-discuss-Typing-icon")[0].title, "Demo is typing...");
// simulate receive typing notification from demo "no longer is typing"
await afterNextRender(() =>
@@ -0,0 +1,14 @@
/* @odoo-module */
import { discussTypingService } from "@mail/discuss/typing/typing_service";
import { setupManager } from "@mail/../tests/helpers/webclient_setup";
import { patch } from "@web/core/utils/patch";
patch(setupManager, "discuss/typing", {
setupServices() {
return {
...this._super(...arguments),
"discuss.typing": discussTypingService,
};
},
});
+1
View File
@@ -18,6 +18,7 @@
"@web_mobile/*": ["web_mobile/static/src/*"],
"@web_grid/*": ["web_grid/static/src/*"],
"@web_studio/*": ["web_studio/static/src/*"],
"@bus/*": ["addons/bus/static/src/*"],
"@mail/*": ["addons/mail/static/src/*"],
"@point_of_sale/*": ["addons/point_of_sale/static/src/*"],