* feat(whatsapp): add emoji reactions UI
Adds end-to-end agent UI for emoji reactions on WhatsApp inboxes
(Cloud API, Baileys, Z-API). Reactions arrive as Messages with
is_reaction=true; this PR exposes them in the bubble UI and lets
agents react with toggle/replace/remove semantics.
- Add POST /reactions endpoint with toggle/replace logic that handles
multi-device echoes from the same connected number
- Add Channel::Whatsapp#supports_reactions? capability
- Add Message.hide_removed_reactions scope and use it in conversation
card preview / last_non_activity_message
- Enrich last_non_activity_message with in_reply_to_snippet for
reaction previews in chat list
- Frontend: hover EmojiReactionPicker (8 quick + full picker) with
alignment-aware positioning, single ReactionDisplay chip aggregating
emojis with total count, conversation card preview shows "Você
reagiu" for own/multi-device echoes
* fix: address CodeRabbit review feedback
- MessagePreview: render "Você" for outgoing reaction echoes that have no
sender (multi-device echoes from the connected number)
- MessagesView#findCurrentUserReaction: prefer active reactions over
deleted rows so a stale deleted echo cannot hijack the toggle target
- conversationHelper: drop removed reactions up-front so the activity
fallback never returns null when older non-removed messages exist
- imap_import rake: wrap IMAP work in begin/ensure so the session is
closed even when uid_search/scan_new_email_uids raises
- ReactionDisplay: include reaction.id in the user row so v-for keys
stay stable across re-renders
* fix: address CodeRabbit round 2 feedback
- enterprise Message override of mark_pending_conversation_as_open_for_human_response
now early-returns on reaction? so reactions can no longer auto-open Captain-pending
conversations (matches the OSS guard)
- whatsapp incoming reaction-removal handlers (Cloud/Baileys/Z-API) look up the
reaction Message globally by sender instead of through the inbound conversation
scope, then operate on existing.conversation; otherwise an old/resolved thread
could be silently no-op'd while the inbound flow created a stray empty thread
- EmojiReactionPicker: localize quick-emoji tooltip labels via i18n keys
- Message.vue: track pendingTimeouts and clear them on unmount so the cooldown
setTimeout no longer touches state after teardown
- toggleMessageReaction action returns the API promise so callers can reconcile
if the cable echo is delayed
* fix: address CodeRabbit round 3 feedback
- MessageFinder#page_window: pluck the 20-row window IDs before taking .min
so the latest page honors PAGE_LIMIT (ActiveRecord's .minimum(:id) ignores
.limit and aggregates over the whole relation)
- ReactionsController#current_user_reaction: rank active reactions ahead of
deleted rows (same invariant as the frontend lookup) so a stale deleted
echo can no longer hijack the toggle target and resurrect itself
- Whatsapp incoming handlers (Cloud, Baileys individual & group, Z-API) now
branch on reaction_removal? BEFORE set_conversation / find_or_create_group_
conversation, so a blank reaction-removal webhook can never open or create
a stray thread just to no-op
- Message#reaction?: strict-boolean cast (via ActiveModel::Type::Boolean)
so a stored string "false" no longer leaks through .present? as truthy
* fix: address CodeRabbit round 4 feedback
- MessageList: anchor unread divider on the filtered visibleMessages
(firstUnreadId can land on a reaction that's filtered out, otherwise
the separator silently disappears)
- ReactionDisplay: render the removable user row as a real <button> when
it's the current user's reaction so keyboard users can focus/activate it
- MessagesView#findCurrentUserReaction: read sender_type from m.sender_type
OR m.sender?.type so REST-loaded messages match the same row instead of
spawning a duplicate optimistic reaction
- Whatsapp incoming reaction-removal lookup (Cloud, Baileys, Z-API): pick
the newest active row first and only fall back to the newest deleted row
when no active reaction exists, mirroring the controller invariant
- CardMessagePreview: use MESSAGE_TYPE.OUTGOING constant in place of the
literal 1 for the multi-device reaction echo check
* fix: address CodeRabbit round 5 feedback
- ReactionsController#ensure_target_is_reactable: reject activity,
template, failed, is_unsupported and missing-source_id targets so the
API mirrors the client toolbar gate and refuses reactions that could
never land on WhatsApp
- MessageList reaction aggregator: treat "agent reacted via Chatwoot"
and "agent reacted via the connected phone" as the same self bucket
so the chip no longer double-counts the current user when both shapes
coexist for one target
- internalChat ReactionDisplay: render the removable user row as a real
<button> so keyboard users can focus and trigger removal (mirrors the
fix already applied to components-next/message/ReactionDisplay)
- EventDataPresenter#push_last_non_activity_message: reorder
created_at: :desc before .first so the cable snapshot publishes the
latest preview instead of the oldest row
- Z-API mark_existing_reaction_as_removed: drop the blanket
`return unless incoming_message?` and route the lookup by direction
(contact sender for incoming removals, senderless outgoing row for
multi-device removals from the connected phone). Chatwoot-originated
echoes stay idempotent because the active-first guard finds nothing
once the controller has flipped deleted=true locally
- spec: assert reaction removal does not change messages.count on the
in-place Cloud path
* fix: address CodeRabbit round 6 feedback
- ReactionsController: validate the emoji payload is a single grapheme
cluster containing a Unicode Emoji codepoint (not just <=32 bytes), so
arbitrary short strings like "ok" or "123" can no longer be persisted
as a reaction or enqueued as a WhatsApp reaction send
- target_unreactable_error: add the content_attributes['deleted'] guard
to mirror the frontend picker gate on deleted messages
- IncomingMessageBaseService: move contact_processable? AFTER the
reaction_removal? early-return so a blocked contact's removal webhook
can still reconcile an existing reaction row instead of leaving a
stale chip/preview
- imap_import rake: add safe_close_imap(imap) that falls back to
disconnect when logout raises Net::IMAP::Error, mirroring
terminate_imap_connection in BaseFetchEmailService, and replace the
three ensure-block imap&.logout sites with it
* fix: address CodeRabbit round 7 feedback
- CardMessagePreview: resolve `lastNonActivityMessage` against the live
`messages` array by id before rendering, so the chat-card preview
picks up the freshest copy instead of the (possibly stale) snapshot
that was mutated in place by a reaction toggle / multi-device echo
- Message + ReactionDisplay: thread an `inboxSupportsReactions` →
`read-only` prop into the chip so non-supported channels (eg.
360Dialog) render historical reactions without a clickable
toggle/remove path that would only hit a 422
- conversations/index.js: replace the truthiness `&&` guard around the
out-of-order MESSAGE_UPDATED check with `Number.isFinite` parsing so
a malformed/missing `updated_at` is treated as stale instead of
silently overwriting a fresher local row
- Baileys mark_existing_reaction_as_removed: drop the blanket
`return unless incoming?` and split the lookup by direction
(sender for incoming, sender-less outgoing for multi-device removals)
to mirror the Z-API/Cloud handlers
- Whatsapp reaction-removal lookup (Cloud, Baileys, Z-API): drop the
fallback to the newest deleted row so a Chatwoot-originated removal
echo no-ops cleanly instead of bumping `updated_at` and dispatching
another `conversation.updated`
- conversation jbuilder: explicit `reorder(created_at: :desc)` on
`last_non_activity_message` so REST and cable both serialize the
same most-recent preview
* fix: address CodeRabbit round 8 feedback
- ReactionsController#current_user_reaction: also match on
content_attributes.in_reply_to_external_id = @target_message.source_id
(via OR with the existing in_reply_to check), so WhatsApp-echoed
reactions persisted by the incoming handlers — where in_reply_to could
be blanked if the target wasn't resolvable at save time — are found and
toggled instead of stacking a duplicate self-reaction
- Mirror the same defensive OR check in the frontend
MessagesView#findCurrentUserReaction, and thread the target's
source_id through the toggleReaction event from Message.vue so the
lookup sees it
* fix: address CodeRabbit round 9 feedback
- emoji_payload_valid?: tighten the final property check from \p{Emoji}
to \p{Extended_Pictographic} so plain "1", "#", "*" (which Unicode
tags as Emoji because they're valid keycap bases) are rejected as
reaction payloads
- EmojiReactionPicker: mirror the translated `title` into `aria-label`
on the icon-only smile-plus / plus buttons so screen readers announce
a meaningful action name
- internalChat ReactionDisplay: close the popover when the post-removal
state would leave ≤1 reactions, so a singleton-user popover never
lingers after removing one of a pair
- EventDataPresenter + conversation jbuilder: strip HTML before
truncating `in_reply_to_snippet` so reactions to email/HTML bubbles
don't surface literal "<p>..." markup in the chat-list preview
* fix: address CodeRabbit round 10 feedback
- MessageList#reactionsByMessageId: break createdAt ties with `<=` so a
later iteration wins on second-resolution tie; two toggles in the same
second no longer leave the chip pointing at a stale row
- MessagePreview: require a non-empty `message.attachments` array (via
`?.length`) before taking the attachment preview branch, so a removed
reaction with `[]` no longer renders the attachment placeholder
- MessagesView#findCurrentUserReaction: replace the sort-based pick with
a reduce that deterministically takes the last element on tie, so a
fast toggle can't hit a stale/deleted row with the same created_at
- Baileys group handler: guard against `@sender_contact.blank?` before
dispatching mark_existing_reaction_as_removed, otherwise a nil sender
would fall into the senderless-outgoing branch and match the wrong row
- WhatsApp reaction-removal lookups (Cloud, Baileys, Z-API): scope the
base query to `inbox_id: inbox.id` so a colliding WhatsApp message id
across inboxes can never mutate a reaction row from another inbox
* fix: address CodeRabbit round 11 feedback
- ReactionsController#emoji_payload_valid?: broaden the final property
check to accept flag and keycap emoji. `\p{Extended_Pictographic}` by
itself is per-codepoint, so 🇧🇷 (two Regional Indicators) and 1️⃣
(digit + VS16 + U+20E3) failed validation. Allow any grapheme cluster
that contains at least one pictographic codepoint, a Regional
Indicator, or the combining keycap, while still rejecting plain
ASCII like "ok", "1", "#"
- Message.vue#canShowReactionToolbar: hide the picker when the target
has no provider source_id, mirroring the server guard in
ReactionsController#target_unreactable_error instead of letting the
click fall through to a 422
- MessageList#reactionsByMessageId: fall back to a sourceId → id
lookup when a reaction only carries `inReplyToExternalId` (WhatsApp
echo / phone-originated), so its chip still renders against the
target bubble after reload
- getLastMessage: merge the fresher store fields onto the API
snapshot instead of replacing it, so jbuilder-only enrichments like
`in_reply_to_snippet` survive the store refresh
* fix(reactions): preserve API fields on card preview and expose a11y state on quick picker
* fix(reactions): consistent originalId resolution, natural PT-BR snippet phrasing, accurate outgoing-echo spec
* fix(reactions): reject requests missing emoji param and align zapi outgoing-echo spec fixture
* fix(reactions): activity preview fallback, camelCase event listener, EN snippet quoting, fromMe group removals, REST chat-only preview
* fix(reactions): reject non-string emoji, scope page reactions to window, exempt reactions from human_response, add cloud multi-device removal
* test(message): isolate hide_removed_reactions deleted-branch from blank-content branch
* fix(reactions): coerce in_reply_to_snippet to plain String
strip_tags returns an ActiveSupport::SafeBuffer; truncate preserves the
class. When this snippet flowed into ActionCableBroadcastJob via the
CONVERSATION_UPDATED dispatch, Sidekiq's strict-args check rejected the
non-native JSON type, raising synchronously through the dispatcher and
turning the reactions controller response into a 500 even though the row
had already persisted. The UI then surfaced the generic 'failed to update
reaction' toast despite the chip rendering correctly.
Wrap with String.new so the broadcast payload contains plain Strings.
* fix(reactions): don't auto-scroll to bottom on reaction add
ADD_MESSAGE emits SCROLL_TO_MESSAGE for every new push, which makes
sense for regular outgoing messages (the user just hit send and wants
to see it). Reactions render as chips on the parent bubble, so the
auto-scroll yanked the agent away from whichever older message they
were reacting to. Skip the emit when the incoming message is flagged
as a reaction.
* fix(reactions): skip scroll on conversation-only updates triggered by reactions
The reactions controller dispatches CONVERSATION_UPDATED so the chat list
preview can refresh in place. UPDATE_CONVERSATION mutation always emitted
SCROLL_TO_MESSAGE for the open conversation, so every toggle yanked the
viewport back to the bottom even after the previous fix in ADD_MESSAGE.
When the refreshed preview row is itself a reaction the update is
preview-only and the scroll is unwanted; for a regular incoming message
the latest non-activity row is the message itself, which still triggers
the scroll as before.
* fix(reactions): anchor compact picker to button side instead of centering
The compact picker was centered on the smile button, so half its width
always extended toward the bubble side and overflowed past the chat edge
on short messages. Anchor it to the button's outer side and nudge 4px
toward the bubble so it lines up with the trigger.
* test(reactions): regression coverage for safebuffer + scroll skip
The previous CodeRabbit rounds shipped three bugs the existing specs
didn't catch: a SafeBuffer return from `strip_tags` that 500'd the
reactions controller via Sidekiq strict-args, and two SCROLL_TO_MESSAGE
emits (one per mutation) that yanked the open conversation to the
bottom on every emoji toggle. Lock all three behaviors.
Also tighten the spec policy in AGENTS.md so new features default to
having specs instead of skipping them.
* test(baileys): align send_message_body helper with id:updated_at format
The reactions PR switched chatwootMessageId to "<id>:<updated_at>" so
toggle/replace cycles get a fresh idempotency key against baileys-api,
but the shared spec helper still merged the bare integer id. 18 baileys
provider specs were silently broken on CI as a result.
* fix(reactions): skip set_contact for unknown reaction-removal webhooks
Reaction-removal cloud webhooks were unconditionally creating a contact
even when the sender was unknown and there was nothing to remove,
because set_contact ran before the reaction_removal? short-circuit
(needed earlier so blocked-contact reconciliation works). Add a
sender-agnostic existence check on the inbox/in_reply_to scope and bail
out before set_contact when no candidate row exists.
Also realign two specs that were not updated when the chatwootMessageId
format gained an `:updated_at` suffix and when zapi reaction-removal
short-circuited instead of creating a Message.
* test(conversation): include last_non_activity_message in push_data fixture
Reactions PR added last_non_activity_message to the push_data payload
but conversation_spec's exact-match expectation wasn't updated, so the
sharded CI shard that landed on this file flipped red.
928 lines
31 KiB
Vue
928 lines
31 KiB
Vue
<script>
|
|
import { ref, provide, useTemplateRef } from 'vue';
|
|
import { useElementSize } from '@vueuse/core';
|
|
// composable
|
|
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
|
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|
import { useAlert, usePendingAlert } from 'dashboard/composables';
|
|
|
|
// components
|
|
import ReplyBox from './ReplyBox.vue';
|
|
import MessageList from 'next/message/MessageList.vue';
|
|
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
|
import Banner from 'dashboard/components/ui/Banner.vue';
|
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
|
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
|
|
|
|
// stores and apis
|
|
import { mapGetters } from 'vuex';
|
|
|
|
// mixins
|
|
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
|
|
|
// utils
|
|
import { emitter } from 'shared/helpers/mitt';
|
|
import { getTypingUsersText } from '../../../helper/commons';
|
|
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
|
import {
|
|
filterDuplicateSourceMessages,
|
|
getReadMessages,
|
|
getUnreadMessages,
|
|
} from 'dashboard/helper/conversationHelper';
|
|
|
|
// constants
|
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|
import { REPLY_POLICY } from 'shared/constants/links';
|
|
import wootConstants from 'dashboard/constants/globals';
|
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
import WhatsappLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue';
|
|
import { isInboxAdminInGroup } from 'dashboard/helper/phoneHelper';
|
|
|
|
export default {
|
|
components: {
|
|
MessageList,
|
|
ReplyBox,
|
|
Banner,
|
|
ConversationLabelSuggestion,
|
|
Spinner,
|
|
ResizableEditorWrapper,
|
|
WhatsappLinkDeviceModal,
|
|
},
|
|
mixins: [inboxMixin],
|
|
setup() {
|
|
const { isAdmin } = useAdmin();
|
|
const isPopOutReplyBox = ref(false);
|
|
const conversationPanelRef = ref(null);
|
|
const resizableEditorWrapperRef = ref(null);
|
|
const messagesViewRef = useTemplateRef('messagesViewRef');
|
|
const topBannerRef = useTemplateRef('topBannerRef');
|
|
const { height: containerHeight } = useElementSize(messagesViewRef);
|
|
const { height: topBannerHeight } = useElementSize(topBannerRef);
|
|
|
|
const keyboardEvents = {
|
|
Escape: {
|
|
action: () => {
|
|
isPopOutReplyBox.value = false;
|
|
},
|
|
},
|
|
};
|
|
|
|
useKeyboardEvents(keyboardEvents);
|
|
|
|
const {
|
|
captainTasksEnabled,
|
|
isLabelSuggestionFeatureEnabled,
|
|
getLabelSuggestions,
|
|
} = useLabelSuggestions();
|
|
|
|
provide('contextMenuElementTarget', conversationPanelRef);
|
|
|
|
return {
|
|
captainTasksEnabled,
|
|
getLabelSuggestions,
|
|
isLabelSuggestionFeatureEnabled,
|
|
conversationPanelRef,
|
|
resizableEditorWrapperRef,
|
|
messagesViewRef,
|
|
topBannerRef,
|
|
containerHeight,
|
|
topBannerHeight,
|
|
isAdmin,
|
|
isPopOutReplyBox,
|
|
};
|
|
},
|
|
data() {
|
|
return {
|
|
isLoadingPrevious: true,
|
|
heightBeforeLoad: null,
|
|
conversationPanel: null,
|
|
hasUserScrolled: false,
|
|
isProgrammaticScroll: false,
|
|
messageSentSinceOpened: false,
|
|
labelSuggestions: [],
|
|
showLinkDeviceModal: false,
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
...mapGetters({
|
|
currentChat: 'getSelectedChat',
|
|
currentUserId: 'getCurrentUserID',
|
|
currentUser: 'getCurrentUser',
|
|
listLoadingStatus: 'getAllMessagesLoaded',
|
|
currentAccountId: 'getCurrentAccountId',
|
|
globalConfig: 'globalConfig/get',
|
|
}),
|
|
currentInbox() {
|
|
return this.$store.getters['inboxes/getInbox'](this.currentChat.inbox_id);
|
|
},
|
|
isOpen() {
|
|
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
|
},
|
|
shouldShowLabelSuggestions() {
|
|
return (
|
|
this.isOpen &&
|
|
this.captainTasksEnabled &&
|
|
this.isLabelSuggestionFeatureEnabled &&
|
|
!this.messageSentSinceOpened
|
|
);
|
|
},
|
|
inboxId() {
|
|
return this.currentChat.inbox_id;
|
|
},
|
|
inbox() {
|
|
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
|
},
|
|
typingUsersList() {
|
|
const userList = this.$store.getters[
|
|
'conversationTypingStatus/getUserList'
|
|
](this.currentChat.id);
|
|
return userList;
|
|
},
|
|
isAnyoneTyping() {
|
|
const userList = this.typingUsersList;
|
|
return userList.length !== 0;
|
|
},
|
|
typingUserNames() {
|
|
const userList = this.typingUsersList;
|
|
if (this.isAnyoneTyping) {
|
|
const [i18nKey, params] = getTypingUsersText(userList);
|
|
return this.$t(i18nKey, params);
|
|
}
|
|
|
|
return '';
|
|
},
|
|
getMessages() {
|
|
const messages = this.currentChat.messages || [];
|
|
if (this.isAWhatsAppChannel) {
|
|
return filterDuplicateSourceMessages(messages);
|
|
}
|
|
return messages;
|
|
},
|
|
readMessages() {
|
|
return getReadMessages(
|
|
this.getMessages,
|
|
this.currentChat.agent_last_seen_at
|
|
);
|
|
},
|
|
unReadMessages() {
|
|
return getUnreadMessages(
|
|
this.getMessages,
|
|
this.currentChat.agent_last_seen_at
|
|
);
|
|
},
|
|
shouldShowSpinner() {
|
|
return (
|
|
(this.currentChat && this.currentChat.dataFetched === undefined) ||
|
|
(!this.listLoadingStatus && this.isLoadingPrevious)
|
|
);
|
|
},
|
|
// Check there is a instagram inbox exists with the same instagram_id
|
|
hasDuplicateInstagramInbox() {
|
|
const instagramId = this.inbox.instagram_id;
|
|
const { additional_attributes: additionalAttributes = {} } = this.inbox;
|
|
const instagramInbox =
|
|
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
|
|
instagramId
|
|
);
|
|
|
|
return (
|
|
this.inbox.channel_type === INBOX_TYPES.FB &&
|
|
additionalAttributes.type === 'instagram_direct_message' &&
|
|
instagramInbox
|
|
);
|
|
},
|
|
|
|
replyWindowBannerMessage() {
|
|
if (this.isAWhatsAppChannel) {
|
|
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
|
}
|
|
if (this.isAPIInbox) {
|
|
const { additional_attributes: additionalAttributes = {} } = this.inbox;
|
|
if (additionalAttributes) {
|
|
const {
|
|
agent_reply_time_window_message: agentReplyTimeWindowMessage,
|
|
agent_reply_time_window: agentReplyTimeWindow,
|
|
} = additionalAttributes;
|
|
return (
|
|
agentReplyTimeWindowMessage ||
|
|
this.$t('CONVERSATION.API_HOURS_WINDOW', {
|
|
hours: agentReplyTimeWindow,
|
|
})
|
|
);
|
|
}
|
|
return '';
|
|
}
|
|
return this.$t('CONVERSATION.CANNOT_REPLY');
|
|
},
|
|
replyWindowLink() {
|
|
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
|
|
return REPLY_POLICY.FACEBOOK;
|
|
}
|
|
if (this.isAWhatsAppCloudChannel) {
|
|
return REPLY_POLICY.WHATSAPP_CLOUD;
|
|
}
|
|
if (this.isATiktokChannel) {
|
|
return REPLY_POLICY.TIKTOK;
|
|
}
|
|
if (!this.isAPIInbox) {
|
|
return REPLY_POLICY.TWILIO_WHATSAPP;
|
|
}
|
|
return '';
|
|
},
|
|
replyWindowLinkText() {
|
|
if (
|
|
this.isAWhatsAppChannel ||
|
|
this.isAFacebookInbox ||
|
|
this.isAnInstagramChannel
|
|
) {
|
|
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
|
}
|
|
if (this.isATiktokChannel) {
|
|
return this.$t('CONVERSATION.48_HOURS_WINDOW');
|
|
}
|
|
if (!this.isAPIInbox) {
|
|
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
|
}
|
|
return '';
|
|
},
|
|
unreadMessageCount() {
|
|
return this.currentChat.unread_count || 0;
|
|
},
|
|
unreadMessageLabel() {
|
|
const count =
|
|
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
|
|
const label =
|
|
this.unreadMessageCount > 1
|
|
? 'CONVERSATION.UNREAD_MESSAGES'
|
|
: 'CONVERSATION.UNREAD_MESSAGE';
|
|
return `${count} ${this.$t(label)}`;
|
|
},
|
|
inboxSupportsReplyTo() {
|
|
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
|
const outgoing =
|
|
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
|
|
!this.is360DialogWhatsAppChannel;
|
|
|
|
return { incoming, outgoing };
|
|
},
|
|
inboxSupportsEdit() {
|
|
// Currently only Baileys WhatsApp channel supports message editing
|
|
return this.isAWhatsAppBaileysChannel;
|
|
},
|
|
inboxSupportsReactions() {
|
|
return (
|
|
this.isAWhatsAppCloudChannel ||
|
|
this.isAWhatsAppBaileysChannel ||
|
|
this.isAWhatsAppZapiChannel
|
|
);
|
|
},
|
|
currentContact() {
|
|
const senderId = this.currentChat?.meta?.sender?.id;
|
|
if (!senderId) return {};
|
|
return this.$store.getters['contacts/getContact'](senderId);
|
|
},
|
|
isGroupConversation() {
|
|
return this.currentChat?.group_type === 'group';
|
|
},
|
|
groupContactId() {
|
|
return this.currentChat?.meta?.sender?.id || null;
|
|
},
|
|
groupMembers() {
|
|
if (!this.groupContactId) return [];
|
|
return (
|
|
this.$store.getters['groupMembers/getGroupMembers'](
|
|
this.groupContactId
|
|
) || []
|
|
);
|
|
},
|
|
groupMembersMeta() {
|
|
if (!this.groupContactId) return {};
|
|
return (
|
|
this.$store.getters['groupMembers/getGroupMembersMeta'](
|
|
this.groupContactId
|
|
) || {}
|
|
);
|
|
},
|
|
isInboxAdminInCurrentGroup() {
|
|
const meta = this.groupMembersMeta;
|
|
if (meta.is_inbox_admin != null) return meta.is_inbox_admin;
|
|
const inboxPhone = meta.inbox_phone_number || this.inbox?.phone_number;
|
|
return isInboxAdminInGroup(inboxPhone, this.groupMembers);
|
|
},
|
|
isGroupMembersLoaded() {
|
|
const meta = this.groupMembersMeta;
|
|
return meta.is_inbox_admin != null || this.groupMembers.length > 0;
|
|
},
|
|
isAnnouncementModeRestricted() {
|
|
return (
|
|
this.isAWhatsAppBaileysChannel &&
|
|
this.isGroupConversation &&
|
|
this.currentContact?.additional_attributes?.announce === true &&
|
|
this.isGroupMembersLoaded &&
|
|
!this.isInboxAdminInCurrentGroup
|
|
);
|
|
},
|
|
isGroupLeft() {
|
|
return (
|
|
this.isAWhatsAppBaileysChannel &&
|
|
this.isGroupConversation &&
|
|
this.currentContact?.additional_attributes?.group_left === true
|
|
);
|
|
},
|
|
isGroupsDisabled() {
|
|
return (
|
|
this.isAWhatsAppBaileysChannel &&
|
|
this.isGroupConversation &&
|
|
!this.globalConfig.baileysWhatsappGroupsEnabled
|
|
);
|
|
},
|
|
isSuperAdmin() {
|
|
return this.currentUser.type === 'SuperAdmin';
|
|
},
|
|
inboxProviderConnection() {
|
|
return this.currentInbox.provider_connection?.connection;
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
currentChat(newChat, oldChat) {
|
|
if (newChat.id === oldChat.id) {
|
|
return;
|
|
}
|
|
this.fetchAllAttachmentsFromCurrentChat();
|
|
this.fetchSuggestions();
|
|
this.messageSentSinceOpened = false;
|
|
this.resetReplyEditorHeight();
|
|
},
|
|
groupContactId: {
|
|
immediate: true,
|
|
handler(contactId) {
|
|
if (
|
|
contactId &&
|
|
this.isAWhatsAppBaileysChannel &&
|
|
this.isGroupConversation &&
|
|
!this.isGroupMembersLoaded
|
|
) {
|
|
this.$store.dispatch('groupMembers/fetch', {
|
|
contactId,
|
|
});
|
|
}
|
|
},
|
|
},
|
|
},
|
|
|
|
created() {
|
|
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
|
// when a message is sent we set the flag to true this hides the label suggestions,
|
|
// until the chat is changed and the flag is reset in the watch for currentChat
|
|
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
|
this.messageSentSinceOpened = true;
|
|
});
|
|
},
|
|
|
|
mounted() {
|
|
this.addScrollListener();
|
|
this.fetchAllAttachmentsFromCurrentChat();
|
|
this.fetchSuggestions();
|
|
},
|
|
|
|
unmounted() {
|
|
this.removeBusListeners();
|
|
this.removeScrollListener();
|
|
},
|
|
|
|
methods: {
|
|
async fetchSuggestions() {
|
|
// start empty, this ensures that the label suggestions are not shown
|
|
this.labelSuggestions = [];
|
|
|
|
if (this.isLabelSuggestionDismissed()) {
|
|
return;
|
|
}
|
|
|
|
// Early exit if conversation already has labels - no need to suggest more
|
|
const existingLabels = this.currentChat?.labels || [];
|
|
if (existingLabels.length > 0) return;
|
|
|
|
if (!this.captainTasksEnabled || !this.isLabelSuggestionFeatureEnabled) {
|
|
return;
|
|
}
|
|
|
|
this.labelSuggestions = await this.getLabelSuggestions();
|
|
|
|
// once the labels are fetched, we need to scroll to bottom
|
|
// but we need to wait for the DOM to be updated
|
|
// so we use the nextTick method
|
|
this.$nextTick(() => {
|
|
// this param is added to route, telling the UI to navigate to the message
|
|
// it is triggered by the SCROLL_TO_MESSAGE method
|
|
// see setActiveChat on ConversationView.vue for more info
|
|
const { messageId } = this.$route.query;
|
|
|
|
// only trigger the scroll to bottom if the user has not scrolled
|
|
// and there's no active messageId that is selected in view
|
|
if (!messageId && !this.hasUserScrolled) {
|
|
this.scrollToBottom();
|
|
}
|
|
});
|
|
},
|
|
isLabelSuggestionDismissed() {
|
|
return LocalStorage.getFlag(
|
|
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
|
this.currentAccountId,
|
|
this.currentChat.id
|
|
);
|
|
},
|
|
fetchAllAttachmentsFromCurrentChat() {
|
|
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
|
|
},
|
|
removeBusListeners() {
|
|
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
|
},
|
|
onScrollToMessage({ messageId = '' } = {}) {
|
|
this.$nextTick(() => {
|
|
const messageElement = document.getElementById('message' + messageId);
|
|
if (messageElement) {
|
|
this.isProgrammaticScroll = true;
|
|
messageElement.scrollIntoView({ behavior: 'smooth' });
|
|
if (messageId) {
|
|
emitter.emit(BUS_EVENTS.HIGHLIGHT_MESSAGE, { messageId });
|
|
}
|
|
} else if (messageId) {
|
|
this.fetchAndScrollToMessage(messageId);
|
|
} else {
|
|
this.scrollToBottom();
|
|
}
|
|
});
|
|
this.makeMessagesRead();
|
|
},
|
|
async fetchAndScrollToMessage(messageId) {
|
|
const dismissSearch = usePendingAlert(
|
|
this.$t('SCHEDULED_MESSAGES.ITEM.SEARCHING_MESSAGE')
|
|
);
|
|
try {
|
|
await this.$store.dispatch('fetchPreviousMessages', {
|
|
conversationId: this.currentChat.id,
|
|
after: messageId,
|
|
});
|
|
this.$nextTick(() => {
|
|
dismissSearch();
|
|
const messageElement = document.getElementById('message' + messageId);
|
|
if (messageElement) {
|
|
this.isProgrammaticScroll = true;
|
|
messageElement.scrollIntoView({ behavior: 'smooth' });
|
|
emitter.emit(BUS_EVENTS.HIGHLIGHT_MESSAGE, { messageId });
|
|
} else {
|
|
useAlert(this.$t('SCHEDULED_MESSAGES.ITEM.MESSAGE_NOT_FOUND'));
|
|
}
|
|
});
|
|
} catch {
|
|
dismissSearch();
|
|
useAlert(this.$t('SCHEDULED_MESSAGES.ITEM.MESSAGE_NOT_FOUND'));
|
|
}
|
|
},
|
|
addScrollListener() {
|
|
this.conversationPanel = this.$el.querySelector('.conversation-panel');
|
|
this.setScrollParams();
|
|
this.conversationPanel.addEventListener('scroll', this.handleScroll);
|
|
this.$nextTick(() => this.scrollToBottom());
|
|
this.isLoadingPrevious = false;
|
|
},
|
|
removeScrollListener() {
|
|
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
|
|
},
|
|
scrollToBottom() {
|
|
this.isProgrammaticScroll = true;
|
|
let relevantMessages = [];
|
|
|
|
// label suggestions are not part of the messages list
|
|
// so we need to handle them separately
|
|
let labelSuggestions =
|
|
this.conversationPanel.querySelector('.label-suggestion');
|
|
|
|
// if there are unread messages, scroll to the first unread message
|
|
if (this.unreadMessageCount > 0) {
|
|
// capturing only the unread messages
|
|
relevantMessages =
|
|
this.conversationPanel.querySelectorAll('.message--unread');
|
|
} else if (labelSuggestions) {
|
|
// when scrolling to the bottom, the label suggestions is below the last message
|
|
// so we scroll there if there are no unread messages
|
|
// Unread messages always take the highest priority
|
|
relevantMessages = [labelSuggestions];
|
|
} else {
|
|
// if there are no unread messages or label suggestion, scroll to the last message
|
|
// capturing last message from the messages list
|
|
relevantMessages = Array.from(
|
|
this.conversationPanel.querySelectorAll('.message--read')
|
|
).slice(-1);
|
|
}
|
|
|
|
this.conversationPanel.scrollTop = calculateScrollTop(
|
|
this.conversationPanel.scrollHeight,
|
|
this.$el.scrollHeight,
|
|
relevantMessages
|
|
);
|
|
},
|
|
setScrollParams() {
|
|
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
|
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
|
},
|
|
|
|
async fetchPreviousMessages(scrollTop = 0) {
|
|
this.setScrollParams();
|
|
const shouldLoadMoreMessages =
|
|
this.currentChat.dataFetched === true &&
|
|
!this.listLoadingStatus &&
|
|
!this.isLoadingPrevious;
|
|
|
|
if (
|
|
scrollTop < 100 &&
|
|
!this.isLoadingPrevious &&
|
|
shouldLoadMoreMessages
|
|
) {
|
|
this.isLoadingPrevious = true;
|
|
try {
|
|
await this.$store.dispatch('fetchPreviousMessages', {
|
|
conversationId: this.currentChat.id,
|
|
before: this.currentChat.messages[0].id,
|
|
});
|
|
const heightDifference =
|
|
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
|
|
this.conversationPanel.scrollTop =
|
|
this.scrollTopBeforeLoad + heightDifference;
|
|
this.setScrollParams();
|
|
} catch (error) {
|
|
// Ignore Error
|
|
} finally {
|
|
this.isLoadingPrevious = false;
|
|
}
|
|
}
|
|
},
|
|
|
|
handleScroll(e) {
|
|
if (this.isProgrammaticScroll) {
|
|
// Reset the flag
|
|
this.isProgrammaticScroll = false;
|
|
this.hasUserScrolled = false;
|
|
} else {
|
|
this.hasUserScrolled = true;
|
|
}
|
|
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
|
|
this.fetchPreviousMessages(e.target.scrollTop);
|
|
},
|
|
|
|
makeMessagesRead() {
|
|
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
|
},
|
|
async handleMessageRetry(message) {
|
|
if (!message) return;
|
|
const payload = useSnakeCase(message);
|
|
await this.$store.dispatch('sendMessageWithData', payload);
|
|
},
|
|
async handleToggleReaction({ messageId, targetSourceId, emoji }) {
|
|
// Backend keeps a single Message row per (target, user) and toggles it
|
|
// in-place. The cable echo always carries the original create's echo_id,
|
|
// so creating a fresh optimistic per toggle leaves the new one orphaned
|
|
// in the store (the cable matches the real msg id, never the new echo).
|
|
// Those orphans show up as "reagiu <emoji>" in the chat list preview
|
|
// even after the user toggles off. Update the existing entry instead.
|
|
const existing = this.findCurrentUserReaction(messageId, targetSourceId);
|
|
if (existing) {
|
|
await this.applyToggleOnExisting(existing, messageId, emoji);
|
|
} else {
|
|
await this.applyToggleOnNew(messageId, emoji);
|
|
}
|
|
},
|
|
async applyToggleOnExisting(existing, messageId, emoji) {
|
|
const isActive =
|
|
existing.content && !existing.content_attributes?.deleted;
|
|
const isToggleOff =
|
|
isActive && (emoji === '' || existing.content === emoji);
|
|
const newAttrs = { ...(existing.content_attributes || {}) };
|
|
if (isToggleOff) newAttrs.deleted = true;
|
|
else delete newAttrs.deleted;
|
|
|
|
const previous = {
|
|
content: existing.content,
|
|
content_attributes: existing.content_attributes,
|
|
};
|
|
this.$store.dispatch('updateMessage', {
|
|
...existing,
|
|
content: isToggleOff ? '' : emoji,
|
|
content_attributes: newAttrs,
|
|
});
|
|
|
|
try {
|
|
await this.$store.dispatch('toggleMessageReaction', {
|
|
conversationId: this.currentChat.id,
|
|
messageId,
|
|
emoji,
|
|
echoId: existing.echo_id,
|
|
});
|
|
} catch (error) {
|
|
this.$store.dispatch('updateMessage', { ...existing, ...previous });
|
|
useAlert(this.$t('CONVERSATION.REACTIONS.FAILED'));
|
|
}
|
|
},
|
|
async applyToggleOnNew(messageId, emoji) {
|
|
const optimistic = this.buildOptimisticReaction(messageId, emoji);
|
|
this.$store.dispatch('addMessage', optimistic);
|
|
|
|
try {
|
|
await this.$store.dispatch('toggleMessageReaction', {
|
|
conversationId: this.currentChat.id,
|
|
messageId,
|
|
emoji,
|
|
echoId: optimistic.echo_id,
|
|
});
|
|
} catch (error) {
|
|
this.$store.dispatch('updateMessage', {
|
|
...optimistic,
|
|
content_attributes: {
|
|
...optimistic.content_attributes,
|
|
deleted: true,
|
|
},
|
|
});
|
|
useAlert(this.$t('CONVERSATION.REACTIONS.FAILED'));
|
|
}
|
|
},
|
|
findCurrentUserReaction(messageId, targetSourceId = null) {
|
|
const messages = this.currentChat?.messages || [];
|
|
const matches = messages.filter(m => {
|
|
if (!m.content_attributes?.is_reaction) return false;
|
|
// Match both in_reply_to (set by Chatwoot-originated reactions) and
|
|
// in_reply_to_external_id (set by WhatsApp echoes). Without the
|
|
// external id check, a multi-device reaction sent from the connected
|
|
// phone would be invisible here, and the next toggle would stack a
|
|
// duplicate optimistic row instead of mutating the echoed one.
|
|
const matchesInReplyTo =
|
|
m.content_attributes?.in_reply_to === messageId;
|
|
const matchesExternalId =
|
|
targetSourceId &&
|
|
m.content_attributes?.in_reply_to_external_id === targetSourceId;
|
|
if (!matchesInReplyTo && !matchesExternalId) return false;
|
|
// REST jbuilder doesn't surface sender_type; only the nested
|
|
// sender.type. ActionCable push_event_data has the top-level field.
|
|
// Read both so REST-loaded agent reactions match instead of stacking
|
|
// a duplicate optimistic row.
|
|
const senderType = (
|
|
m.sender_type ||
|
|
m.sender?.type ||
|
|
''
|
|
).toLowerCase();
|
|
const senderId = m.sender?.id ?? m.sender_id;
|
|
// Reaction created via Chatwoot UI by the current user
|
|
if (senderType === 'user' && senderId === this.currentUserId) {
|
|
return true;
|
|
}
|
|
// Multi-device echo: agent reacted from the WhatsApp mobile app on
|
|
// the same number connected to this inbox, so it has no agent in
|
|
// Chatwoot. Treat it as ours so a click toggles/removes it instead
|
|
// of stacking a duplicate reaction on top.
|
|
return m.message_type === 1 && senderId == null;
|
|
});
|
|
// Prefer active rows so we never resurrect a stale deleted echo when
|
|
// there is a fresher live reaction sitting next to it. created_at is
|
|
// second-resolution, so a sort can keep the older entry first on ties.
|
|
// Reduce with >= so that, all else equal, the later iteration wins —
|
|
// giving a deterministic "newest" pick even for two toggles in the same
|
|
// second.
|
|
const pickLatest = list =>
|
|
list.reduce((latest, candidate) => {
|
|
if (!latest) return candidate;
|
|
return (candidate.created_at || 0) >= (latest.created_at || 0)
|
|
? candidate
|
|
: latest;
|
|
}, null);
|
|
const isActive = r => !!r.content && !r.content_attributes?.deleted;
|
|
return pickLatest(matches.filter(isActive)) || pickLatest(matches);
|
|
},
|
|
buildOptimisticReaction(messageId, emoji) {
|
|
// Use the echo_id as the temporary id so findPendingMessageIndex matches
|
|
// the real Message arriving later via ActionCable (it carries echo_id).
|
|
const echoId = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
return {
|
|
id: echoId,
|
|
echo_id: echoId,
|
|
content: emoji,
|
|
conversation_id: this.currentChat?.id,
|
|
message_type: 1,
|
|
content_type: 'text',
|
|
content_attributes: {
|
|
is_reaction: true,
|
|
in_reply_to: messageId,
|
|
},
|
|
additional_attributes: {},
|
|
attachments: [],
|
|
sender: this.currentUser,
|
|
sender_type: 'User',
|
|
sender_id: this.currentUserId,
|
|
private: false,
|
|
status: 'progress',
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
};
|
|
},
|
|
toggleReplyEditorSize() {
|
|
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
|
|
},
|
|
resetReplyEditorHeight() {
|
|
this.resizableEditorWrapperRef?.resetEditorHeight?.();
|
|
},
|
|
getInReplyToMessage(parentMessage) {
|
|
if (!parentMessage) return {};
|
|
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
|
|
if (!inReplyToMessageId) return {};
|
|
|
|
return this.currentChat?.messages.find(message => {
|
|
if (message.id === inReplyToMessageId) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
},
|
|
onOpenGroupsEnabledLink() {
|
|
window.open(wootConstants.FAZER_AI_GUIDES_URL, '_blank');
|
|
},
|
|
onOpenLinkDeviceModal() {
|
|
this.showLinkDeviceModal = true;
|
|
},
|
|
onCloseLinkDeviceModal() {
|
|
this.showLinkDeviceModal = false;
|
|
},
|
|
onSetupProviderConnection() {
|
|
this.$store
|
|
.dispatch('inboxes/setupChannelProvider', this.inbox.id)
|
|
.catch(e => {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Error setting up provider connection:', e);
|
|
useAlert(
|
|
this.$t(
|
|
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.RECONNECT_FAILED'
|
|
)
|
|
);
|
|
});
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="messagesViewRef"
|
|
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
|
|
>
|
|
<div ref="topBannerRef">
|
|
<template v-if="isAWhatsAppBaileysChannel || isAWhatsAppZapiChannel">
|
|
<WhatsappLinkDeviceModal
|
|
v-if="showLinkDeviceModal"
|
|
:show="showLinkDeviceModal"
|
|
:on-close="onCloseLinkDeviceModal"
|
|
:inbox="currentInbox"
|
|
/>
|
|
<Banner
|
|
v-if="inboxProviderConnection !== 'open'"
|
|
color-scheme="alert"
|
|
class="mt-2 mx-2 rounded-lg overflow-hidden"
|
|
:banner-message="
|
|
isAdmin
|
|
? $t(
|
|
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED'
|
|
)
|
|
: $t(
|
|
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.NOT_CONNECTED_CONTACT_ADMIN'
|
|
)
|
|
"
|
|
has-action-button
|
|
:action-button-label="
|
|
isAdmin
|
|
? $t(
|
|
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.LINK_DEVICE'
|
|
)
|
|
: ''
|
|
"
|
|
:action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'"
|
|
@primary-action="
|
|
isAdmin ? onOpenLinkDeviceModal() : onSetupProviderConnection()
|
|
"
|
|
/>
|
|
</template>
|
|
<Banner
|
|
v-if="!currentChat.can_reply"
|
|
color-scheme="alert"
|
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
|
:banner-message="replyWindowBannerMessage"
|
|
:href-link="replyWindowLink"
|
|
:href-link-text="replyWindowLinkText"
|
|
/>
|
|
<Banner
|
|
v-else-if="hasDuplicateInstagramInbox"
|
|
color-scheme="alert"
|
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
|
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
|
/>
|
|
<Banner
|
|
v-else-if="isGroupLeft"
|
|
color-scheme="alert"
|
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
|
:banner-message="$t('CONVERSATION.GROUP_LEFT_BANNER')"
|
|
/>
|
|
<Banner
|
|
v-else-if="isAnnouncementModeRestricted"
|
|
color-scheme="alert"
|
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
|
:banner-message="$t('CONVERSATION.ANNOUNCEMENT_MODE_BANNER')"
|
|
/>
|
|
<Banner
|
|
v-if="isGroupsDisabled && isSuperAdmin"
|
|
color-scheme="warning"
|
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
|
:banner-message="$t('CONVERSATION.GROUPS_DISABLED_BANNER')"
|
|
:notice-message="$t('GENERAL_SETTINGS.SUPER_ADMIN_ONLY_NOTICE')"
|
|
has-action-button
|
|
:action-button-label="$t('CONVERSATION.GROUPS_DISABLED_CTA')"
|
|
@primary-action="onOpenGroupsEnabledLink"
|
|
/>
|
|
<Banner
|
|
v-else-if="isGroupsDisabled"
|
|
color-scheme="warning"
|
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
|
:banner-message="$t('CONVERSATION.GROUPS_DISABLED_BANNER_NON_ADMIN')"
|
|
/>
|
|
</div>
|
|
<MessageList
|
|
ref="conversationPanelRef"
|
|
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
|
|
:current-user-id="currentUserId"
|
|
:first-unread-id="unReadMessages[0]?.id"
|
|
:is-an-email-channel="isAnEmailChannel"
|
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
|
:inbox-supports-edit="inboxSupportsEdit"
|
|
:inbox-supports-reactions="inboxSupportsReactions"
|
|
:messages="getMessages"
|
|
@retry="handleMessageRetry"
|
|
@toggle-reaction="handleToggleReaction"
|
|
>
|
|
<template #beforeAll>
|
|
<transition name="slide-up">
|
|
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
|
<li
|
|
class="min-h-[4rem] flex flex-shrink-0 flex-grow-0 items-center flex-auto justify-center max-w-full mt-0 mr-0 mb-1 ml-0 relative first:mt-auto last:mb-0"
|
|
>
|
|
<Spinner v-if="shouldShowSpinner" class="text-n-brand" />
|
|
</li>
|
|
</transition>
|
|
</template>
|
|
<template #unreadBadge>
|
|
<li
|
|
v-show="unreadMessageCount != 0"
|
|
class="list-none flex justify-center items-center"
|
|
>
|
|
<span
|
|
class="shadow-lg rounded-full bg-n-brand text-white text-xs font-medium my-2.5 mx-auto px-2.5 py-1.5"
|
|
>
|
|
{{ unreadMessageLabel }}
|
|
</span>
|
|
</li>
|
|
</template>
|
|
<template #after>
|
|
<ConversationLabelSuggestion
|
|
v-if="shouldShowLabelSuggestions"
|
|
:suggested-labels="labelSuggestions"
|
|
:chat-labels="currentChat.labels"
|
|
:conversation-id="currentChat.id"
|
|
/>
|
|
</template>
|
|
</MessageList>
|
|
<div class="flex relative flex-col bg-n-surface-1">
|
|
<div
|
|
v-if="isAnyoneTyping"
|
|
class="absolute flex items-center w-full h-0 -top-7"
|
|
>
|
|
<div
|
|
class="flex py-2 pr-4 pl-5 shadow-md rounded-full bg-white dark:bg-n-solid-3 text-n-slate-11 text-xs font-semibold my-2.5 mx-auto"
|
|
>
|
|
{{ typingUserNames }}
|
|
<img
|
|
class="w-6 ltr:ml-2 rtl:mr-2"
|
|
src="assets/images/typing.gif"
|
|
alt="Someone is typing"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<ResizableEditorWrapper
|
|
ref="resizableEditorWrapperRef"
|
|
:container-height="Math.max(0, containerHeight - topBannerHeight)"
|
|
>
|
|
<ReplyBox @toggle-editor-size="toggleReplyEditorSize" />
|
|
</ResizableEditorWrapper>
|
|
</div>
|
|
</div>
|
|
</template>
|