fixes: #11834 This pull request introduces TikTok channel integration, enabling users to connect and manage TikTok business accounts similarly to other supported social channels. The changes span backend API endpoints, authentication helpers, webhook handling, configuration, and frontend components to support TikTok as a first-class channel. **Key Notes** * This integration is only compatible with TikTok Business Accounts * Special permissions are required to access the TikTok [Business Messaging API](https://business-api.tiktok.com/portal/docs?id=1832183871604753). * The Business Messaging API is region-restricted and is currently unavailable to users in the EU. * Only TEXT, IMAGE, and POST_SHARE messages are currently supported due to limitations in the TikTok Business Messaging API * A message will be successfully sent only if it contains text alone or one image attachment. Messages with multiple attachments or those combining text and attachments will fail and receive a descriptive error status. * Messages sent directly from the TikTok App will be synced into the system * Initiating a new conversation from the system is not permitted due to limitations from the TikTok Business Messaging API. **Backend: TikTok Channel Integration** * Added `Api::V1::Accounts::Tiktok::AuthorizationsController` to handle TikTok OAuth authorization initiation, returning the TikTok authorization URL. * Implemented `Tiktok::CallbacksController` to handle TikTok OAuth callback, process authorization results, create or update channel/inbox, and handle errors or denied scopes. * Added `Webhooks::TiktokController` to receive and verify TikTok webhook events, including signature verification and event dispatching. * Created `Tiktok::IntegrationHelper` module for JWT-based token generation and verification for secure TikTok OAuth state management. **Configuration and Feature Flags** * Added TikTok app credentials (`TIKTOK_APP_ID`, `TIKTOK_APP_SECRET`) to allowed configs and app config, and registered TikTok as a feature in the super admin features YAML. [[1]](diffhunk://#diff-5e46e1d248631a1147521477d84a54f8ba6846ea21c61eca5f70042d960467f4R43) [[2]](diffhunk://#diff-8bf37a019cab1dedea458c437bd93e34af1d6e22b1672b1d43ef6eaa4dcb7732R69) [[3]](diffhunk://#diff-123164bea29f3c096b0d018702b090d5ae670760c729141bd4169a36f5f5c1caR74-R79) **Frontend: TikTok Channel UI and Messaging Support** * Added `TiktokChannel` API client for frontend TikTok authorization requests. * Updated channel icon mappings and tests to include TikTok (`Channel::Tiktok`). [[1]](diffhunk://#diff-b852739ed45def61218d581d0de1ba73f213f55570aa5eec52aaa08f380d0e16R16) [[2]](diffhunk://#diff-3cd3ae32e94ef85f1f2c4435abf0775cc0614fb37ee25d97945cd51573ef199eR64-R69) * Enabled TikTok as a supported channel in contact forms, channel widgets, and feature toggles. [[1]](diffhunk://#diff-ec59c85e1403aaed1a7de35971fe16b7033d5cd763be590903ebf8f1ca25a010R47) [[2]](diffhunk://#diff-ec59c85e1403aaed1a7de35971fe16b7033d5cd763be590903ebf8f1ca25a010R69) [[3]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R26-R29) [[4]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R51-R54) [[5]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R68) * Updated message meta logic to support TikTok-specific message statuses (sent, delivered, read). [[1]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696R23) [[2]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L63-R65) [[3]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L81-R84) [[4]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L103-R107) * Added support for embedded message attachments (e.g., TikTok embeds) with a new `EmbedBubble` component and updated message rendering logic. [[1]](diffhunk://#diff-c3d701caf27d9c31e200c6143c11a11b9d8826f78aa2ce5aa107470e6fdb9d7fR31) [[2]](diffhunk://#diff-047859f9368a46d6d20177df7d6d623768488ecc38a5b1e284f958fad49add68R1-R19) [[3]](diffhunk://#diff-c3d701caf27d9c31e200c6143c11a11b9d8826f78aa2ce5aa107470e6fdb9d7fR316) [[4]](diffhunk://#diff-cbc85e7c4c8d56f2a847d0b01cd48ef36e5f87b43023bff0520fdfc707283085R52) * Adjusted reply policy and UI messaging for TikTok's 48-hour reply window. [[1]](diffhunk://#diff-0d691f6a983bd89502f91253ecf22e871314545d1e3d3b106fbfc76bf6d8e1c7R208-R210) [[2]](diffhunk://#diff-0d691f6a983bd89502f91253ecf22e871314545d1e3d3b106fbfc76bf6d8e1c7R224-R226) These changes collectively enable end-to-end TikTok channel support, from configuration and OAuth flow to webhook processing and frontend message handling. ------------ # TikTok App Setup & Configuration 1. Grant access to the Business Messaging API ([Documentation](https://business-api.tiktok.com/portal/docs?id=1832184145137922)) 2. Set the app authorization redirect URL to `https://FRONTEND_URL/tiktok/callback` 3. Update the installation config with TikTok App ID and Secret 4. Create a Business Messaging Webhook configuration and set the callback url to `https://FRONTEND_URL/webhooks/tiktok` ([Documentation](https://business-api.tiktok.com/portal/docs?id=1832190670631937)) . You can do this by calling `Tiktok::AuthClient.update_webhook_callback` from rails console once you finish Tiktok channel configuration in super admin ( will be automated in future ) 5. Enable TikTok channel feature in an account --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
566 lines
18 KiB
Vue
566 lines
18 KiB
Vue
<script setup>
|
|
import { onMounted, computed, ref, toRefs } from 'vue';
|
|
import { useTimeoutFn } from '@vueuse/core';
|
|
import { provideMessageContext } from './provider.js';
|
|
import { useTrack } from 'dashboard/composables';
|
|
import { emitter } from 'shared/helpers/mitt';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute } from 'vue-router';
|
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
|
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|
import {
|
|
MESSAGE_TYPES,
|
|
ATTACHMENT_TYPES,
|
|
MESSAGE_VARIANTS,
|
|
SENDER_TYPES,
|
|
ORIENTATION,
|
|
MESSAGE_STATUS,
|
|
CONTENT_TYPES,
|
|
} from './constants';
|
|
|
|
import Avatar from 'next/avatar/Avatar.vue';
|
|
|
|
import TextBubble from './bubbles/Text/Index.vue';
|
|
import ActivityBubble from './bubbles/Activity.vue';
|
|
import ImageBubble from './bubbles/Image.vue';
|
|
import FileBubble from './bubbles/File.vue';
|
|
import AudioBubble from './bubbles/Audio.vue';
|
|
import VideoBubble from './bubbles/Video.vue';
|
|
import EmbedBubble from './bubbles/Embed.vue';
|
|
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
|
import EmailBubble from './bubbles/Email/Index.vue';
|
|
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
|
import ContactBubble from './bubbles/Contact.vue';
|
|
import DyteBubble from './bubbles/Dyte.vue';
|
|
import LocationBubble from './bubbles/Location.vue';
|
|
import CSATBubble from './bubbles/CSAT.vue';
|
|
import FormBubble from './bubbles/Form.vue';
|
|
import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
|
|
|
import MessageError from './MessageError.vue';
|
|
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
|
|
|
/**
|
|
* @typedef {Object} Attachment
|
|
* @property {number} id - Unique identifier for the attachment
|
|
* @property {number} messageId - ID of the associated message
|
|
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
|
|
* @property {number} accountId - ID of the associated account
|
|
* @property {string|null} extension - File extension
|
|
* @property {string} dataUrl - URL to access the full attachment data
|
|
* @property {string} thumbUrl - URL to access the thumbnail version
|
|
* @property {number} fileSize - Size of the file in bytes
|
|
* @property {number|null} width - Width of the image if applicable
|
|
* @property {number|null} height - Height of the image if applicable
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} Sender
|
|
* @property {Object} additional_attributes - Additional attributes of the sender
|
|
* @property {Object} custom_attributes - Custom attributes of the sender
|
|
* @property {string} email - Email of the sender
|
|
* @property {number} id - ID of the sender
|
|
* @property {string|null} identifier - Identifier of the sender
|
|
* @property {string} name - Name of the sender
|
|
* @property {string|null} phone_number - Phone number of the sender
|
|
* @property {string} thumbnail - Thumbnail URL of the sender
|
|
* @property {string} type - Type of sender
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} ContentAttributes
|
|
* @property {string} externalError - an error message to be shown if the message failed to send
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} Props
|
|
* @property {('sent'|'delivered'|'read'|'failed'|'progress')} status - The delivery status of the message
|
|
* @property {ContentAttributes} [contentAttributes={}] - Additional attributes of the message content
|
|
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
|
|
* @property {Sender|null} [sender=null] - The sender information
|
|
* @property {boolean} [private=false] - Whether the message is private
|
|
* @property {number|null} [senderId=null] - The ID of the sender
|
|
* @property {number} createdAt - Timestamp when the message was created
|
|
* @property {number} currentUserId - The ID of the current user
|
|
* @property {number} id - The unique identifier for the message
|
|
* @property {number} messageType - The type of message (must be one of MESSAGE_TYPES)
|
|
* @property {string|null} [error=null] - Error message if the message failed to send
|
|
* @property {string|null} [senderType=null] - The type of the sender
|
|
* @property {string} content - The message content
|
|
* @property {boolean} [groupWithNext=false] - Whether the message should be grouped with the next message
|
|
* @property {Object|null} [inReplyTo=null] - The message to which this message is a reply
|
|
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
|
|
* @property {number} conversationId - The ID of the conversation to which the message belongs
|
|
* @property {number} inboxId - The ID of the inbox to which the message belongs
|
|
*/
|
|
|
|
// eslint-disable-next-line vue/define-macros-order
|
|
const props = defineProps({
|
|
id: { type: Number, required: true },
|
|
messageType: {
|
|
type: Number,
|
|
required: true,
|
|
validator: value => Object.values(MESSAGE_TYPES).includes(value),
|
|
},
|
|
status: {
|
|
type: String,
|
|
required: true,
|
|
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
|
},
|
|
attachments: { type: Array, default: () => [] },
|
|
content: { type: String, default: null },
|
|
contentAttributes: { type: Object, default: () => ({}) },
|
|
contentType: {
|
|
type: String,
|
|
default: 'text',
|
|
validator: value => Object.values(CONTENT_TYPES).includes(value),
|
|
},
|
|
conversationId: { type: Number, required: true },
|
|
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
|
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
|
groupWithNext: { type: Boolean, default: false },
|
|
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
|
|
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
|
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
|
isEmailInbox: { type: Boolean, default: false },
|
|
private: { type: Boolean, default: false },
|
|
sender: { type: Object, default: null },
|
|
senderId: { type: Number, default: null },
|
|
senderType: { type: String, default: null },
|
|
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
|
|
});
|
|
|
|
const emit = defineEmits(['retry']);
|
|
|
|
const contextMenuPosition = ref({});
|
|
const showBackgroundHighlight = ref(false);
|
|
const showContextMenu = ref(false);
|
|
const { t } = useI18n();
|
|
const route = useRoute();
|
|
|
|
/**
|
|
* Computes the message variant based on props
|
|
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
|
*/
|
|
const variant = computed(() => {
|
|
if (props.private) return MESSAGE_VARIANTS.PRIVATE;
|
|
|
|
if (props.isEmailInbox) {
|
|
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
|
|
if (emailInboxTypes.includes(props.messageType)) {
|
|
return MESSAGE_VARIANTS.EMAIL;
|
|
}
|
|
}
|
|
|
|
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
|
return MESSAGE_VARIANTS.EMAIL;
|
|
}
|
|
|
|
if (props.status === MESSAGE_STATUS.FAILED) return MESSAGE_VARIANTS.ERROR;
|
|
if (props.contentAttributes?.isUnsupported)
|
|
return MESSAGE_VARIANTS.UNSUPPORTED;
|
|
|
|
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
|
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
|
return MESSAGE_VARIANTS.BOT;
|
|
}
|
|
|
|
const variants = {
|
|
[MESSAGE_TYPES.INCOMING]: MESSAGE_VARIANTS.USER,
|
|
[MESSAGE_TYPES.ACTIVITY]: MESSAGE_VARIANTS.ACTIVITY,
|
|
[MESSAGE_TYPES.OUTGOING]: MESSAGE_VARIANTS.AGENT,
|
|
[MESSAGE_TYPES.TEMPLATE]: MESSAGE_VARIANTS.TEMPLATE,
|
|
};
|
|
|
|
return variants[props.messageType] || MESSAGE_VARIANTS.USER;
|
|
});
|
|
|
|
const isBotOrAgentMessage = computed(() => {
|
|
if (props.messageType === MESSAGE_TYPES.ACTIVITY) {
|
|
return false;
|
|
}
|
|
// if an outgoing message is still processing, then it's definitely a
|
|
// message sent by the current user
|
|
if (
|
|
props.status === MESSAGE_STATUS.PROGRESS &&
|
|
props.messageType === MESSAGE_TYPES.OUTGOING
|
|
) {
|
|
return true;
|
|
}
|
|
const senderId = props.senderId ?? props.sender?.id;
|
|
const senderType = props.sender?.type ?? props.senderType;
|
|
|
|
if (!senderType || !senderId) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
[SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(
|
|
senderType
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase();
|
|
});
|
|
|
|
/**
|
|
* Computes the message orientation based on sender type and message type
|
|
* @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation
|
|
*/
|
|
const orientation = computed(() => {
|
|
if (isBotOrAgentMessage.value) {
|
|
return ORIENTATION.RIGHT;
|
|
}
|
|
|
|
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return ORIENTATION.CENTER;
|
|
|
|
return ORIENTATION.LEFT;
|
|
});
|
|
|
|
const flexOrientationClass = computed(() => {
|
|
const map = {
|
|
[ORIENTATION.LEFT]: 'justify-start',
|
|
[ORIENTATION.RIGHT]: 'justify-end',
|
|
[ORIENTATION.CENTER]: 'justify-center',
|
|
};
|
|
|
|
return map[orientation.value];
|
|
});
|
|
|
|
const gridClass = computed(() => {
|
|
const map = {
|
|
[ORIENTATION.LEFT]: 'grid grid-cols-1fr',
|
|
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]',
|
|
};
|
|
|
|
return map[orientation.value];
|
|
});
|
|
|
|
const gridTemplate = computed(() => {
|
|
const map = {
|
|
[ORIENTATION.LEFT]: `
|
|
"bubble"
|
|
"meta"
|
|
`,
|
|
[ORIENTATION.RIGHT]: `
|
|
"bubble avatar"
|
|
"meta spacer"
|
|
`,
|
|
};
|
|
|
|
return map[orientation.value];
|
|
});
|
|
|
|
const shouldGroupWithNext = computed(() => {
|
|
if (props.status === MESSAGE_STATUS.FAILED) return false;
|
|
|
|
return props.groupWithNext;
|
|
});
|
|
|
|
const shouldShowAvatar = computed(() => {
|
|
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
|
|
if (orientation.value === ORIENTATION.LEFT) return false;
|
|
|
|
return true;
|
|
});
|
|
|
|
const componentToRender = computed(() => {
|
|
if (props.isEmailInbox && !props.private) {
|
|
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
|
|
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
|
|
}
|
|
|
|
if (props.contentType === CONTENT_TYPES.INPUT_CSAT) {
|
|
return CSATBubble;
|
|
}
|
|
|
|
if (
|
|
[CONTENT_TYPES.INPUT_SELECT, CONTENT_TYPES.FORM].includes(props.contentType)
|
|
) {
|
|
return FormBubble;
|
|
}
|
|
|
|
if (props.contentType === CONTENT_TYPES.VOICE_CALL) {
|
|
return VoiceCallBubble;
|
|
}
|
|
|
|
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
|
return EmailBubble;
|
|
}
|
|
|
|
if (props.contentAttributes?.isUnsupported) {
|
|
return UnsupportedBubble;
|
|
}
|
|
|
|
if (props.contentAttributes.type === 'dyte') {
|
|
return DyteBubble;
|
|
}
|
|
|
|
const instagramSharedTypes = [
|
|
ATTACHMENT_TYPES.STORY_MENTION,
|
|
ATTACHMENT_TYPES.IG_STORY,
|
|
ATTACHMENT_TYPES.IG_POST,
|
|
];
|
|
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
|
return InstagramStoryBubble;
|
|
}
|
|
|
|
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
|
const fileType = props.attachments[0].fileType;
|
|
|
|
if (!props.content) {
|
|
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
|
|
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
|
|
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
|
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
|
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
|
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
|
|
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
|
}
|
|
// Attachment content is the name of the contact
|
|
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
|
|
}
|
|
|
|
return TextBubble;
|
|
});
|
|
|
|
const shouldShowContextMenu = computed(() => {
|
|
return !props.contentAttributes?.isUnsupported;
|
|
});
|
|
|
|
const isBubble = computed(() => {
|
|
return props.messageType !== MESSAGE_TYPES.ACTIVITY;
|
|
});
|
|
|
|
const isMessageDeleted = computed(() => {
|
|
return props.contentAttributes?.deleted;
|
|
});
|
|
|
|
const payloadForContextMenu = computed(() => {
|
|
return {
|
|
id: props.id,
|
|
content_attributes: props.contentAttributes,
|
|
content: props.content,
|
|
conversation_id: props.conversationId,
|
|
};
|
|
});
|
|
|
|
const contextMenuEnabledOptions = computed(() => {
|
|
const hasText = !!props.content;
|
|
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
|
|
|
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
|
|
const isFailedOrProcessing =
|
|
props.status === MESSAGE_STATUS.FAILED ||
|
|
props.status === MESSAGE_STATUS.PROGRESS;
|
|
|
|
return {
|
|
copy: hasText,
|
|
delete:
|
|
(hasText || hasAttachments) &&
|
|
!isFailedOrProcessing &&
|
|
!isMessageDeleted.value,
|
|
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
|
|
copyLink: !isFailedOrProcessing,
|
|
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
|
|
replyTo:
|
|
!props.private &&
|
|
props.inboxSupportsReplyTo.outgoing &&
|
|
!isFailedOrProcessing,
|
|
};
|
|
});
|
|
|
|
const shouldRenderMessage = computed(() => {
|
|
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
|
const isEmailContentType = props.contentType === CONTENT_TYPES.INCOMING_EMAIL;
|
|
const isUnsupported = props.contentAttributes?.isUnsupported;
|
|
const isAnIntegrationMessage =
|
|
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
|
|
|
return (
|
|
hasAttachments ||
|
|
props.content ||
|
|
isEmailContentType ||
|
|
isUnsupported ||
|
|
isAnIntegrationMessage
|
|
);
|
|
});
|
|
|
|
function openContextMenu(e) {
|
|
const shouldSkipContextMenu =
|
|
e.target?.classList.contains('skip-context-menu') ||
|
|
['a', 'img'].includes(e.target?.tagName.toLowerCase());
|
|
if (shouldSkipContextMenu || getSelection().toString()) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
if (e.type === 'contextmenu') {
|
|
useTrack(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
|
|
}
|
|
contextMenuPosition.value = {
|
|
x: e.pageX || e.clientX,
|
|
y: e.pageY || e.clientY,
|
|
};
|
|
showContextMenu.value = true;
|
|
}
|
|
|
|
function closeContextMenu() {
|
|
showContextMenu.value = false;
|
|
contextMenuPosition.value = { x: null, y: null };
|
|
}
|
|
|
|
function handleReplyTo() {
|
|
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
|
const { conversationId, id: replyTo } = props;
|
|
|
|
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
|
|
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, props);
|
|
}
|
|
|
|
const avatarInfo = computed(() => {
|
|
// If no sender, return bot info
|
|
if (!props.sender) {
|
|
return {
|
|
name: t('CONVERSATION.BOT'),
|
|
src: '',
|
|
};
|
|
}
|
|
|
|
const { sender } = props;
|
|
const { name, type, avatarUrl, thumbnail } = sender || {};
|
|
|
|
// If sender type is agent bot, use avatarUrl
|
|
if ([SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(type)) {
|
|
return {
|
|
name: name ?? '',
|
|
src: avatarUrl ?? '',
|
|
};
|
|
}
|
|
|
|
// For all other senders, use thumbnail
|
|
return {
|
|
name: name ?? '',
|
|
src: thumbnail ?? '',
|
|
};
|
|
});
|
|
|
|
const avatarTooltip = computed(() => {
|
|
if (avatarInfo.value.name === '') return '';
|
|
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
|
});
|
|
|
|
const setupHighlightTimer = () => {
|
|
if (Number(route.query.messageId) !== Number(props.id)) {
|
|
return;
|
|
}
|
|
|
|
showBackgroundHighlight.value = true;
|
|
const HIGHLIGHT_TIMER = 1000;
|
|
useTimeoutFn(() => {
|
|
showBackgroundHighlight.value = false;
|
|
}, HIGHLIGHT_TIMER);
|
|
};
|
|
|
|
onMounted(setupHighlightTimer);
|
|
|
|
provideMessageContext({
|
|
...toRefs(props),
|
|
isPrivate: computed(() => props.private),
|
|
variant,
|
|
orientation,
|
|
isBotOrAgentMessage,
|
|
shouldGroupWithNext,
|
|
});
|
|
</script>
|
|
|
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
|
<template>
|
|
<div
|
|
v-if="shouldRenderMessage"
|
|
:id="`message${props.id}`"
|
|
class="flex mb-2 w-full message-bubble-container"
|
|
:data-message-id="props.id"
|
|
:class="[
|
|
flexOrientationClass,
|
|
{
|
|
'group-with-next': shouldGroupWithNext,
|
|
'bg-n-alpha-1': showBackgroundHighlight,
|
|
},
|
|
]"
|
|
>
|
|
<div v-if="variant === MESSAGE_VARIANTS.ACTIVITY">
|
|
<ActivityBubble :content="content" />
|
|
</div>
|
|
<div
|
|
v-else
|
|
:class="[
|
|
gridClass,
|
|
{
|
|
'gap-y-2': contentAttributes.externalError,
|
|
'w-full': variant === MESSAGE_VARIANTS.EMAIL,
|
|
},
|
|
]"
|
|
class="gap-x-2"
|
|
:style="{
|
|
gridTemplateAreas: gridTemplate,
|
|
}"
|
|
>
|
|
<div
|
|
v-if="!shouldGroupWithNext && shouldShowAvatar"
|
|
v-tooltip.left-end="avatarTooltip"
|
|
class="[grid-area:avatar] flex items-end"
|
|
>
|
|
<Avatar v-bind="avatarInfo" :size="24" />
|
|
</div>
|
|
<div
|
|
class="[grid-area:bubble] flex"
|
|
:class="{
|
|
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
|
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
|
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
|
}"
|
|
@contextmenu="openContextMenu($event)"
|
|
>
|
|
<Component :is="componentToRender" />
|
|
</div>
|
|
<MessageError
|
|
v-if="contentAttributes.externalError"
|
|
class="[grid-area:meta]"
|
|
:class="flexOrientationClass"
|
|
:error="contentAttributes.externalError"
|
|
@retry="emit('retry')"
|
|
/>
|
|
</div>
|
|
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
|
<ContextMenu
|
|
v-if="isBubble"
|
|
:context-menu-position="contextMenuPosition"
|
|
:is-open="showContextMenu"
|
|
:enabled-options="contextMenuEnabledOptions"
|
|
:message="payloadForContextMenu"
|
|
hide-button
|
|
@open="openContextMenu"
|
|
@close="closeContextMenu"
|
|
@reply-to="handleReplyTo"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
.group-with-next + .message-bubble-container {
|
|
.left-bubble {
|
|
@apply ltr:rounded-tl-sm rtl:rounded-tr-sm;
|
|
}
|
|
|
|
.right-bubble {
|
|
@apply ltr:rounded-tr-sm rtl:rounded-tl-sm;
|
|
}
|
|
}
|
|
</style>
|