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>
576 lines
17 KiB
Vue
576 lines
17 KiB
Vue
<script>
|
|
import { ref, provide } from 'vue';
|
|
// composable
|
|
import { useConfig } from 'dashboard/composables/useConfig';
|
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|
import { useAI } from 'dashboard/composables/useAI';
|
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
|
|
|
// 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';
|
|
|
|
// 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';
|
|
|
|
export default {
|
|
components: {
|
|
MessageList,
|
|
ReplyBox,
|
|
Banner,
|
|
ConversationLabelSuggestion,
|
|
Spinner,
|
|
},
|
|
mixins: [inboxMixin],
|
|
setup() {
|
|
const isPopOutReplyBox = ref(false);
|
|
const conversationPanelRef = ref(null);
|
|
const { isEnterprise } = useConfig();
|
|
|
|
const keyboardEvents = {
|
|
Escape: {
|
|
action: () => {
|
|
isPopOutReplyBox.value = false;
|
|
},
|
|
},
|
|
};
|
|
|
|
useKeyboardEvents(keyboardEvents);
|
|
|
|
const {
|
|
isAIIntegrationEnabled,
|
|
isLabelSuggestionFeatureEnabled,
|
|
fetchIntegrationsIfRequired,
|
|
fetchLabelSuggestions,
|
|
} = useAI();
|
|
|
|
provide('contextMenuElementTarget', conversationPanelRef);
|
|
|
|
return {
|
|
isEnterprise,
|
|
isPopOutReplyBox,
|
|
isAIIntegrationEnabled,
|
|
isLabelSuggestionFeatureEnabled,
|
|
fetchIntegrationsIfRequired,
|
|
fetchLabelSuggestions,
|
|
conversationPanelRef,
|
|
};
|
|
},
|
|
data() {
|
|
return {
|
|
isLoadingPrevious: true,
|
|
heightBeforeLoad: null,
|
|
conversationPanel: null,
|
|
hasUserScrolled: false,
|
|
isProgrammaticScroll: false,
|
|
messageSentSinceOpened: false,
|
|
labelSuggestions: [],
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
...mapGetters({
|
|
currentChat: 'getSelectedChat',
|
|
currentUserId: 'getCurrentUserID',
|
|
listLoadingStatus: 'getAllMessagesLoaded',
|
|
currentAccountId: 'getCurrentAccountId',
|
|
}),
|
|
isOpen() {
|
|
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
|
},
|
|
shouldShowLabelSuggestions() {
|
|
return (
|
|
this.isOpen &&
|
|
this.isEnterprise &&
|
|
this.isAIIntegrationEnabled &&
|
|
!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 };
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
currentChat(newChat, oldChat) {
|
|
if (newChat.id === oldChat.id) {
|
|
return;
|
|
}
|
|
this.fetchAllAttachmentsFromCurrentChat();
|
|
this.fetchSuggestions();
|
|
this.messageSentSinceOpened = false;
|
|
},
|
|
},
|
|
|
|
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;
|
|
}
|
|
|
|
if (!this.isEnterprise) {
|
|
return;
|
|
}
|
|
|
|
// Early exit if conversation already has labels - no need to suggest more
|
|
const existingLabels = this.currentChat?.labels || [];
|
|
if (existingLabels.length > 0) return;
|
|
|
|
// method available in mixin, need to ensure that integrations are present
|
|
await this.fetchIntegrationsIfRequired();
|
|
|
|
if (!this.isLabelSuggestionFeatureEnabled) {
|
|
return;
|
|
}
|
|
|
|
this.labelSuggestions = await this.fetchLabelSuggestions({
|
|
conversationId: this.currentChat.id,
|
|
});
|
|
|
|
// 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' });
|
|
this.fetchPreviousMessages();
|
|
} else {
|
|
this.scrollToBottom();
|
|
}
|
|
});
|
|
this.makeMessagesRead();
|
|
},
|
|
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);
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
|
|
<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')"
|
|
/>
|
|
<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"
|
|
:messages="getMessages"
|
|
@retry="handleMessageRetry"
|
|
>
|
|
<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"
|
|
:class="{
|
|
'modal-mask': isPopOutReplyBox,
|
|
'bg-n-background': !isPopOutReplyBox,
|
|
}"
|
|
>
|
|
<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>
|
|
<ReplyBox
|
|
:pop-out-reply-box="isPopOutReplyBox"
|
|
@update:pop-out-reply-box="isPopOutReplyBox = $event"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.modal-mask {
|
|
@apply fixed;
|
|
|
|
&::v-deep {
|
|
.ProseMirror-woot-style {
|
|
@apply max-h-[25rem];
|
|
}
|
|
|
|
.reply-box {
|
|
@apply border border-n-weak max-w-[75rem] w-[70%];
|
|
|
|
&.is-private {
|
|
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
|
|
}
|
|
}
|
|
|
|
.reply-box .reply-box__top {
|
|
@apply relative min-h-[27.5rem];
|
|
}
|
|
|
|
.reply-box__top .input {
|
|
@apply min-h-[27.5rem];
|
|
}
|
|
|
|
.emoji-dialog {
|
|
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
|
|
}
|
|
}
|
|
}
|
|
</style>
|