From cfa0bb953bd0e95490d99118019e1c5c0d682259 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 17 Dec 2025 14:30:49 +0530 Subject: [PATCH 1/3] feat: Custom attribute page redesign (#13087) Fixes https://linear.app/chatwoot/issue/CW-6096/custom-attributes-page-redesign 524664368-b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5 --------- Co-authored-by: iamsivin --- .../AttributeListItem.vue | 88 +++++++++ .../CustomAttributes/AttributeBadge.vue | 42 +++++ .../i18n/locale/en/attributesMgmt.json | 4 + .../dashboard/settings/attributes/Index.vue | 173 +++++++++++++++--- 4 files changed, 280 insertions(+), 27 deletions(-) create mode 100644 app/javascript/dashboard/components-next/ConversationWorkflow/AttributeListItem.vue create mode 100644 app/javascript/dashboard/components-next/CustomAttributes/AttributeBadge.vue diff --git a/app/javascript/dashboard/components-next/ConversationWorkflow/AttributeListItem.vue b/app/javascript/dashboard/components-next/ConversationWorkflow/AttributeListItem.vue new file mode 100644 index 000000000..f5822f67d --- /dev/null +++ b/app/javascript/dashboard/components-next/ConversationWorkflow/AttributeListItem.vue @@ -0,0 +1,88 @@ + + + diff --git a/app/javascript/dashboard/components-next/CustomAttributes/AttributeBadge.vue b/app/javascript/dashboard/components-next/CustomAttributes/AttributeBadge.vue new file mode 100644 index 000000000..fb0c9867a --- /dev/null +++ b/app/javascript/dashboard/components-next/CustomAttributes/AttributeBadge.vue @@ -0,0 +1,42 @@ + + + diff --git a/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json b/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json index cea6359de..78886f451 100644 --- a/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json @@ -129,6 +129,10 @@ "ENABLE_REGEX": { "LABEL": "Enable regex validation" } + }, + "BADGES": { + "PRE_CHAT": "Pre-chat", + "RESOLUTION": "Resolution" } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue index 636892005..bf93ffa1f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue @@ -1,27 +1,49 @@ - { :on-close="hideAddPopup" :selected-attribute-model-tab="selectedTabIndex" /> + + + + From 116ed54c7ee38541c35a0ff46c8cd991309f653e Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 17 Dec 2025 19:03:54 +0530 Subject: [PATCH 2/3] fix: Prioritize SDK `enableFileUpload` flag when explicitly set (#13091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem Currently, the attachment button visibility in the widget uses both the SDK's `enableFileUpload` flag AND the inbox's attachment settings with an AND condition. This creates an issue for users who want to control attachments solely through inbox settings, since the SDK flag defaults to `true` even when not explicitly provided. **Before:** - SDK not set → `enableFileUpload: true` (default) + inbox setting = attachment shown only if both true - SDK set to false → `enableFileUpload: false` + inbox setting = attachment always hidden - SDK set to true → `enableFileUpload: true` + inbox setting = attachment shown only if both true This meant users couldn't rely solely on inbox settings when the SDK flag wasn't explicitly provided. ### Solution Changed the logic to prioritize explicit SDK configuration when provided, and fall back to inbox settings when not provided: **After:** - SDK not set → `enableFileUpload: undefined` → use inbox setting only - SDK set to false → `enableFileUpload: false` → hide attachment (SDK controls) - SDK set to true → `enableFileUpload: true` → show attachment (SDK controls) --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- app/javascript/entrypoints/sdk.js | 2 +- .../widget/components/ChatAttachment.vue | 8 +- .../widget/components/ChatInputWrap.vue | 22 +- .../composables/specs/useAttachments.spec.js | 224 ++++++++++++++++++ .../widget/composables/useAttachments.js | 42 ++++ .../widget/store/modules/appConfig.js | 4 +- 6 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 app/javascript/widget/composables/specs/useAttachments.spec.js create mode 100644 app/javascript/widget/composables/useAttachments.js diff --git a/app/javascript/entrypoints/sdk.js b/app/javascript/entrypoints/sdk.js index 2a919f42c..6e1639b1c 100755 --- a/app/javascript/entrypoints/sdk.js +++ b/app/javascript/entrypoints/sdk.js @@ -76,7 +76,7 @@ const runSDK = ({ baseUrl, websiteToken }) => { welcomeDescription: chatwootSettings.welcomeDescription || '', availableMessage: chatwootSettings.availableMessage || '', unavailableMessage: chatwootSettings.unavailableMessage || '', - enableFileUpload: chatwootSettings.enableFileUpload ?? true, + enableFileUpload: chatwootSettings.enableFileUpload, enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true, enableEndConversation: chatwootSettings.enableEndConversation ?? true, diff --git a/app/javascript/widget/components/ChatAttachment.vue b/app/javascript/widget/components/ChatAttachment.vue index 169749527..64c17e9c6 100755 --- a/app/javascript/widget/components/ChatAttachment.vue +++ b/app/javascript/widget/components/ChatAttachment.vue @@ -11,6 +11,7 @@ import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import { DirectUpload } from 'activestorage'; import { mapGetters } from 'vuex'; import { emitter } from 'shared/helpers/mitt'; +import { useAttachments } from '../composables/useAttachments'; export default { components: { FluentIcon, FileUpload, Spinner }, @@ -20,13 +21,16 @@ export default { default: () => {}, }, }, + setup() { + const { canHandleAttachments } = useAttachments(); + return { canHandleAttachments }; + }, data() { return { isUploading: false }; }, computed: { ...mapGetters({ globalConfig: 'globalConfig/get', - shouldShowFilePicker: 'appConfig/getShouldShowFilePicker', }), fileUploadSizeLimit() { return resolveMaximumFileUploadSize( @@ -46,7 +50,7 @@ export default { methods: { handleClipboardPaste(e) { // If file picker is not enabled, do not allow paste - if (!this.shouldShowFilePicker) return; + if (!this.canHandleAttachments) return; const items = (e.clipboardData || e.originalEvent.clipboardData).items; // items is a DataTransferItemList object which does not have forEach method diff --git a/app/javascript/widget/components/ChatInputWrap.vue b/app/javascript/widget/components/ChatInputWrap.vue index c423ab220..ce8d17455 100755 --- a/app/javascript/widget/components/ChatInputWrap.vue +++ b/app/javascript/widget/components/ChatInputWrap.vue @@ -3,7 +3,7 @@ import { mapGetters } from 'vuex'; import ChatAttachmentButton from 'widget/components/ChatAttachment.vue'; import ChatSendButton from 'widget/components/ChatSendButton.vue'; -import configMixin from '../mixins/configMixin'; +import { useAttachments } from '../composables/useAttachments'; import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import ResizableTextArea from 'shared/components/ResizableTextArea.vue'; @@ -18,7 +18,6 @@ export default { FluentIcon, ResizableTextArea, }, - mixins: [configMixin], props: { onSendMessage: { type: Function, @@ -29,6 +28,18 @@ export default { default: () => {}, }, }, + setup() { + const { + canHandleAttachments, + shouldShowEmojiPicker, + hasEmojiPickerEnabled, + } = useAttachments(); + return { + canHandleAttachments, + shouldShowEmojiPicker, + hasEmojiPickerEnabled, + }; + }, data() { return { userInput: '', @@ -41,15 +52,10 @@ export default { ...mapGetters({ widgetColor: 'appConfig/getWidgetColor', isWidgetOpen: 'appConfig/getIsWidgetOpen', - shouldShowFilePicker: 'appConfig/getShouldShowFilePicker', shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker', }), showAttachment() { - return ( - this.shouldShowFilePicker && - this.hasAttachmentsEnabled && - this.userInput.length === 0 - ); + return this.canHandleAttachments && this.userInput.length === 0; }, showSendButton() { return this.userInput.length > 0; diff --git a/app/javascript/widget/composables/specs/useAttachments.spec.js b/app/javascript/widget/composables/specs/useAttachments.spec.js new file mode 100644 index 000000000..578fd3ae8 --- /dev/null +++ b/app/javascript/widget/composables/specs/useAttachments.spec.js @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useAttachments } from '../useAttachments'; +import { useStore } from 'vuex'; +import { computed } from 'vue'; + +// Mock Vue's useStore +vi.mock('vuex', () => ({ + useStore: vi.fn(), +})); + +// Mock Vue's computed +vi.mock('vue', () => ({ + computed: vi.fn(fn => ({ value: fn() })), +})); + +describe('useAttachments', () => { + let mockStore; + let mockGetters; + + beforeEach(() => { + // Reset window.chatwootWebChannel + delete window.chatwootWebChannel; + + // Create mock store + mockGetters = {}; + mockStore = { + getters: mockGetters, + }; + vi.mocked(useStore).mockReturnValue(mockStore); + + // Mock computed to return a reactive-like object + vi.mocked(computed).mockImplementation(fn => ({ value: fn() })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('shouldShowFilePicker', () => { + it('returns value from store getter', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = true; + + const { shouldShowFilePicker } = useAttachments(); + + expect(shouldShowFilePicker.value).toBe(true); + }); + + it('returns undefined when not set in store', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = undefined; + + const { shouldShowFilePicker } = useAttachments(); + + expect(shouldShowFilePicker.value).toBeUndefined(); + }); + }); + + describe('hasAttachmentsEnabled', () => { + it('returns true when attachments are enabled in channel config', () => { + window.chatwootWebChannel = { + enabledFeatures: ['attachments', 'emoji'], + }; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(true); + }); + + it('returns false when attachments are not enabled in channel config', () => { + window.chatwootWebChannel = { + enabledFeatures: ['emoji'], + }; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(false); + }); + + it('returns false when channel config has no enabled features', () => { + window.chatwootWebChannel = { + enabledFeatures: [], + }; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(false); + }); + + it('returns false when channel config is missing', () => { + window.chatwootWebChannel = undefined; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(false); + }); + + it('returns false when enabledFeatures is missing', () => { + window.chatwootWebChannel = {}; + + const { hasAttachmentsEnabled } = useAttachments(); + + expect(hasAttachmentsEnabled.value).toBe(false); + }); + }); + + describe('canHandleAttachments', () => { + beforeEach(() => { + // Set up a default channel config + window.chatwootWebChannel = { + enabledFeatures: ['attachments'], + }; + }); + + it('prioritizes SDK flag when explicitly set to true', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = true; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(true); + }); + + it('prioritizes SDK flag when explicitly set to false', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = false; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(false); + }); + + it('falls back to inbox settings when SDK flag is undefined', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = undefined; + window.chatwootWebChannel = { + enabledFeatures: ['attachments'], + }; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(true); + }); + + it('falls back to inbox settings when SDK flag is undefined and attachments disabled', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = undefined; + window.chatwootWebChannel = { + enabledFeatures: ['emoji'], + }; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(false); + }); + + it('prioritizes SDK false over inbox settings true', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = false; + window.chatwootWebChannel = { + enabledFeatures: ['attachments'], + }; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(false); + }); + + it('prioritizes SDK true over inbox settings false', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = true; + window.chatwootWebChannel = { + enabledFeatures: ['emoji'], // no attachments + }; + + const { canHandleAttachments } = useAttachments(); + + expect(canHandleAttachments.value).toBe(true); + }); + }); + + describe('hasEmojiPickerEnabled', () => { + it('returns true when emoji picker is enabled in channel config', () => { + window.chatwootWebChannel = { + enabledFeatures: ['emoji_picker', 'attachments'], + }; + + const { hasEmojiPickerEnabled } = useAttachments(); + + expect(hasEmojiPickerEnabled.value).toBe(true); + }); + + it('returns false when emoji picker is not enabled in channel config', () => { + window.chatwootWebChannel = { + enabledFeatures: ['attachments'], + }; + + const { hasEmojiPickerEnabled } = useAttachments(); + + expect(hasEmojiPickerEnabled.value).toBe(false); + }); + }); + + describe('shouldShowEmojiPicker', () => { + it('returns value from store getter', () => { + mockGetters['appConfig/getShouldShowEmojiPicker'] = true; + + const { shouldShowEmojiPicker } = useAttachments(); + + expect(shouldShowEmojiPicker.value).toBe(true); + }); + }); + + describe('integration test', () => { + it('returns all expected properties', () => { + mockGetters['appConfig/getShouldShowFilePicker'] = undefined; + mockGetters['appConfig/getShouldShowEmojiPicker'] = true; + window.chatwootWebChannel = { + enabledFeatures: ['attachments', 'emoji_picker'], + }; + + const result = useAttachments(); + + expect(result).toHaveProperty('shouldShowFilePicker'); + expect(result).toHaveProperty('shouldShowEmojiPicker'); + expect(result).toHaveProperty('hasAttachmentsEnabled'); + expect(result).toHaveProperty('hasEmojiPickerEnabled'); + expect(result).toHaveProperty('canHandleAttachments'); + expect(Object.keys(result)).toHaveLength(5); + }); + }); +}); diff --git a/app/javascript/widget/composables/useAttachments.js b/app/javascript/widget/composables/useAttachments.js new file mode 100644 index 000000000..5ffab60a6 --- /dev/null +++ b/app/javascript/widget/composables/useAttachments.js @@ -0,0 +1,42 @@ +import { computed } from 'vue'; +import { useStore } from 'vuex'; + +export function useAttachments() { + const store = useStore(); + + const shouldShowFilePicker = computed( + () => store.getters['appConfig/getShouldShowFilePicker'] + ); + + const shouldShowEmojiPicker = computed( + () => store.getters['appConfig/getShouldShowEmojiPicker'] + ); + + const hasAttachmentsEnabled = computed(() => { + const channelConfig = window.chatwootWebChannel; + return channelConfig?.enabledFeatures?.includes('attachments') || false; + }); + + const hasEmojiPickerEnabled = computed(() => { + const channelConfig = window.chatwootWebChannel; + return channelConfig?.enabledFeatures?.includes('emoji_picker') || false; + }); + + const canHandleAttachments = computed(() => { + // If enableFileUpload was explicitly set via SDK, prioritize that + if (shouldShowFilePicker.value !== undefined) { + return shouldShowFilePicker.value; + } + + // Otherwise, fall back to inbox settings only + return hasAttachmentsEnabled.value; + }); + + return { + shouldShowFilePicker, + shouldShowEmojiPicker, + hasAttachmentsEnabled, + hasEmojiPickerEnabled, + canHandleAttachments, + }; +} diff --git a/app/javascript/widget/store/modules/appConfig.js b/app/javascript/widget/store/modules/appConfig.js index 3ad5078b8..5b720d907 100644 --- a/app/javascript/widget/store/modules/appConfig.js +++ b/app/javascript/widget/store/modules/appConfig.js @@ -25,7 +25,7 @@ const state = { welcomeDescription: '', availableMessage: '', unavailableMessage: '', - enableFileUpload: true, + enableFileUpload: undefined, enableEmojiPicker: true, enableEndConversation: true, }; @@ -64,7 +64,7 @@ export const actions = { welcomeDescription = '', availableMessage = '', unavailableMessage = '', - enableFileUpload = true, + enableFileUpload = undefined, enableEmojiPicker = true, enableEndConversation = true, } From ca5e112a8c4273cdb0ead51c7caa0da50f7447b8 Mon Sep 17 00:00:00 2001 From: Mazen Khalil Date: Wed, 17 Dec 2025 18:54:50 +0300 Subject: [PATCH 3/3] feat: TikTok channel (#12741) 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 Co-authored-by: iamsivin --- .../tiktok/authorizations_controller.rb | 15 ++ app/controllers/dashboard_controller.rb | 1 + .../super_admin/app_configs_controller.rb | 1 + .../tiktok/callbacks_controller.rb | 144 +++++++++++++++ app/controllers/webhooks/tiktok_controller.rb | 53 ++++++ app/helpers/super_admin/features.yml | 6 + app/helpers/tiktok/integration_helper.rb | 47 +++++ .../dashboard/api/channel/tiktokClient.js | 14 ++ .../dashboard/api/specs/tiktokClient.spec.js | 35 ++++ .../Contacts/ContactsForm/ContactsForm.vue | 2 + .../components-next/icon/provider.js | 1 + .../icon/specs/provider.spec.js | 6 + .../components-next/message/Message.vue | 2 + .../components-next/message/MessageMeta.vue | 10 +- .../components-next/message/bubbles/Embed.vue | 30 +++ .../components-next/message/constants.js | 1 + .../components/widgets/ChannelItem.vue | 9 + .../widgets/conversation/MessagesView.vue | 6 + .../widgets/conversation/ReplyBox.vue | 3 + .../composables/spec/useInbox.spec.js | 8 + .../dashboard/composables/useInbox.js | 7 + app/javascript/dashboard/constants/editor.js | 5 + app/javascript/dashboard/featureFlags.js | 1 + app/javascript/dashboard/helper/inbox.js | 6 + .../dashboard/helper/specs/inbox.spec.js | 13 ++ .../dashboard/i18n/locale/en/chatlist.json | 3 + .../dashboard/i18n/locale/en/contact.json | 3 + .../i18n/locale/en/conversation.json | 3 +- .../dashboard/i18n/locale/en/inboxMgmt.json | 12 ++ .../conversation/contact/ContactForm.vue | 1 + .../conversation/contact/SocialIcons.vue | 1 + .../settings/inbox/ChannelFactory.vue | 2 + .../dashboard/settings/inbox/ChannelList.vue | 30 ++- .../dashboard/settings/inbox/Settings.vue | 6 + .../settings/inbox/channels/Instagram.vue | 126 +++++-------- .../settings/inbox/channels/Tiktok.vue | 79 ++++++++ .../inbox/channels/tiktok/Reauthorize.vue | 37 ++++ .../settings/inbox/components/ChannelName.vue | 1 + .../dashboard/store/modules/inboxes.js | 7 + .../store/modules/specs/inboxes/fixtures.js | 8 + .../modules/specs/inboxes/getters.spec.js | 14 +- .../FluentIcon/dashboard-icons.json | 1 + app/javascript/shared/constants/links.js | 1 + .../shared/helpers/MessageTypeHelper.js | 2 + app/javascript/shared/mixins/inboxMixin.js | 7 + app/jobs/send_reply_job.rb | 1 + app/jobs/webhooks/tiktok_events_job.rb | 69 +++++++ .../channel_notifications_mailer.rb | 5 + app/models/account.rb | 1 + app/models/attachment.rb | 10 +- app/models/channel/tiktok.rb | 45 +++++ app/models/concerns/reauthorizable.rb | 1 + app/models/inbox.rb | 4 + .../conversations/message_window_service.rb | 6 + app/services/tiktok/auth_client.rb | 145 +++++++++++++++ app/services/tiktok/client.rb | 100 ++++++++++ app/services/tiktok/message_service.rb | 174 ++++++++++++++++++ app/services/tiktok/messaging_helpers.rb | 68 +++++++ app/services/tiktok/read_status_service.rb | 36 ++++ app/services/tiktok/send_on_tiktok_service.rb | 47 +++++ app/services/tiktok/token_service.rb | 77 ++++++++ app/views/api/v1/models/_inbox.json.jbuilder | 3 + app/views/layouts/vueapp.html.erb | 1 + .../super_admin/application/_icons.html.erb | 4 + config/features.yml | 3 + config/installation_config.yml | 10 + config/routes.rb | 9 + .../20251027091242_add_tiktok_channel.rb | 15 ++ db/schema.rb | 12 ++ lib/redis/redis_keys.rb | 2 + .../images/dashboard/channels/tiktok.png | Bin 0 -> 24164 bytes .../integrations/channels/badges/tiktok.png | Bin 0 -> 3365 bytes .../tiktok/authorizations_controller_spec.rb | 55 ++++++ .../tiktok/callbacks_controller_spec.rb | 139 ++++++++++++++ .../webhooks/tiktok_controller_spec.rb | 64 +++++++ spec/factories/channel/channel_tiktok.rb | 16 ++ spec/jobs/send_reply_job_spec.rb | 9 + spec/jobs/webhooks/tiktok_events_job_spec.rb | 91 +++++++++ spec/models/attachment_spec.rb | 8 + spec/services/tiktok/message_service_spec.rb | 117 ++++++++++++ .../tiktok/read_status_service_spec.rb | 35 ++++ .../tiktok/send_on_tiktok_service_spec.rb | 71 +++++++ spec/services/tiktok/token_service_spec.rb | 57 ++++++ theme/icons.js | 5 + 84 files changed, 2189 insertions(+), 96 deletions(-) create mode 100644 app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb create mode 100644 app/controllers/tiktok/callbacks_controller.rb create mode 100644 app/controllers/webhooks/tiktok_controller.rb create mode 100644 app/helpers/tiktok/integration_helper.rb create mode 100644 app/javascript/dashboard/api/channel/tiktokClient.js create mode 100644 app/javascript/dashboard/api/specs/tiktokClient.spec.js create mode 100644 app/javascript/dashboard/components-next/message/bubbles/Embed.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Tiktok.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/tiktok/Reauthorize.vue create mode 100644 app/jobs/webhooks/tiktok_events_job.rb create mode 100644 app/models/channel/tiktok.rb create mode 100644 app/services/tiktok/auth_client.rb create mode 100644 app/services/tiktok/client.rb create mode 100644 app/services/tiktok/message_service.rb create mode 100644 app/services/tiktok/messaging_helpers.rb create mode 100644 app/services/tiktok/read_status_service.rb create mode 100644 app/services/tiktok/send_on_tiktok_service.rb create mode 100644 app/services/tiktok/token_service.rb create mode 100644 db/migrate/20251027091242_add_tiktok_channel.rb create mode 100644 public/assets/images/dashboard/channels/tiktok.png create mode 100644 public/integrations/channels/badges/tiktok.png create mode 100644 spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb create mode 100644 spec/controllers/tiktok/callbacks_controller_spec.rb create mode 100644 spec/controllers/webhooks/tiktok_controller_spec.rb create mode 100644 spec/factories/channel/channel_tiktok.rb create mode 100644 spec/jobs/webhooks/tiktok_events_job_spec.rb create mode 100644 spec/services/tiktok/message_service_spec.rb create mode 100644 spec/services/tiktok/read_status_service_spec.rb create mode 100644 spec/services/tiktok/send_on_tiktok_service_spec.rb create mode 100644 spec/services/tiktok/token_service_spec.rb diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb new file mode 100644 index 000000000..7c7320393 --- /dev/null +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -0,0 +1,15 @@ +class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include Tiktok::IntegrationHelper + + def create + redirect_url = Tiktok::AuthClient.authorize_url( + state: generate_tiktok_token(Current.account.id) + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 5003c4c70..abf42517c 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -73,6 +73,7 @@ class DashboardController < ActionController::Base ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), + TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'), WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 1a9539bb6..ec51305b5 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -46,6 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], + 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb new file mode 100644 index 000000000..e484905c3 --- /dev/null +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -0,0 +1,144 @@ +class Tiktok::CallbacksController < ApplicationController + include Tiktok::IntegrationHelper + + def show + return handle_authorization_error if params[:error].present? + return handle_ungranted_scopes_error unless all_scopes_granted? + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + def all_scopes_granted? + granted_scopes = short_term_access_token[:scope].to_s.split(',') + (Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank? + end + + def process_successful_authorization + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) + else + redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + end + + def handle_error(error) + Rails.logger.error("TikTok Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message) + end + + # Handles the case when a user denies permissions or cancels the authorization flow + def handle_authorization_error + redirect_to_error_page( + error_type: params[:error] || 'access_denied', + code: params[:error_code], + error_message: params[:error_description] || 'User cancelled the Authorization' + ) + end + + # Handles the case when a user partially accepted the required scopes + def handle_ungranted_scopes_error + redirect_to_error_page( + error_type: 'ungranted_scopes', + code: 400, + error_message: 'User did not grant all the required scopes' + ) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_type:, code:, error_message:) + redirect_to app_new_tiktok_inbox_url( + account_id: account_id, + error_type: error_type, + code: code, + error_message: error_message + ) + end + + def find_or_create_inbox + business_details = tiktok_client.business_account_details + channel_tiktok = find_channel + channel_exists = channel_tiktok.present? + + if channel_tiktok + update_channel(channel_tiktok, business_details) + else + channel_tiktok = create_channel_with_inbox(business_details) + end + + # reauthorized will also update cache keys for the associated inbox + channel_tiktok.reauthorized! + + set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present? + + [channel_tiktok.inbox, channel_exists] + end + + def create_channel_with_inbox(business_details) + ActiveRecord::Base.transaction do + channel_tiktok = Channel::Tiktok.create!( + account: account, + business_id: short_term_access_token[:business_id], + access_token: short_term_access_token[:access_token], + refresh_token: short_term_access_token[:refresh_token], + expires_at: short_term_access_token[:expires_at], + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + ) + + account.inboxes.create!( + account: account, + channel: channel_tiktok, + name: business_details[:display_name].presence || business_details[:username] + ) + + channel_tiktok + end + end + + def find_channel + Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account) + end + + def update_channel(channel_tiktok, business_details) + channel_tiktok.update!( + access_token: short_term_access_token[:access_token], + refresh_token: short_term_access_token[:refresh_token], + expires_at: short_term_access_token[:expires_at], + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + ) + + channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username]) + end + + def set_avatar(inbox, avatar_url) + Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url) + end + + def account_id + @account_id ||= verify_tiktok_token(params[:state]) + end + + def account + @account ||= Account.find(account_id) + end + + def short_term_access_token + @short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code]) + end + + def tiktok_client + @tiktok_client ||= Tiktok::Client.new( + business_id: short_term_access_token[:business_id], + access_token: short_term_access_token[:access_token] + ) + end +end diff --git a/app/controllers/webhooks/tiktok_controller.rb b/app/controllers/webhooks/tiktok_controller.rb new file mode 100644 index 000000000..efaa1830c --- /dev/null +++ b/app/controllers/webhooks/tiktok_controller.rb @@ -0,0 +1,53 @@ +class Webhooks::TiktokController < ActionController::API + before_action :verify_signature! + + def events + event = JSON.parse(request_payload) + if echo_event? + # Add delay to prevent race condition where echo arrives before send message API completes + # This avoids duplicate messages when echo comes early during API processing + ::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event) + else + ::Webhooks::TiktokEventsJob.perform_later(event) + end + + head :ok + end + + private + + def request_payload + @request_payload ||= request.body.read + end + + def verify_signature! + signature_header = request.headers['Tiktok-Signature'] + client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil) + received_timestamp, received_signature = extract_signature_parts(signature_header) + + return head :unauthorized unless client_secret && received_timestamp && received_signature + + signature_payload = "#{received_timestamp}.#{request_payload}" + computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload) + + return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature) + + # Check timestamp delay (acceptable delay: 5 seconds) + current_timestamp = Time.current.to_i + delay = current_timestamp - received_timestamp + + return head :unauthorized if delay > 5 + end + + def extract_signature_parts(signature_header) + return [nil, nil] if signature_header.blank? + + keys = signature_header.split(',') + signature_parts = keys.map { |part| part.split('=') }.to_h + [signature_parts['t']&.to_i, signature_parts['s']] + end + + def echo_event? + params[:event] == 'im_send_msg' + end +end diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index b05c603cd..34c7a8138 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -78,6 +78,12 @@ instagram: enabled: true icon: 'icon-instagram' config_key: 'instagram' +tiktok: + name: 'TikTok' + description: 'Stay connected with your customers on TikTok' + enabled: true + icon: 'icon-tiktok' + config_key: 'tiktok' whatsapp: name: 'WhatsApp' description: 'Manage your WhatsApp business interactions from Chatwoot.' diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb new file mode 100644 index 000000000..b2de4a092 --- /dev/null +++ b/app/helpers/tiktok/integration_helper.rb @@ -0,0 +1,47 @@ +module Tiktok::IntegrationHelper + # Generates a signed JWT token for Tiktok integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_tiktok_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate TikTok token: #{e.message}") + nil + end + + # Verifies and decodes a Tiktok JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_tiktok_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/api/channel/tiktokClient.js b/app/javascript/dashboard/api/channel/tiktokClient.js new file mode 100644 index 000000000..389eb2699 --- /dev/null +++ b/app/javascript/dashboard/api/channel/tiktokClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class TiktokChannel extends ApiClient { + constructor() { + super('tiktok', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new TiktokChannel(); diff --git a/app/javascript/dashboard/api/specs/tiktokClient.spec.js b/app/javascript/dashboard/api/specs/tiktokClient.spec.js new file mode 100644 index 000000000..5250e2c7b --- /dev/null +++ b/app/javascript/dashboard/api/specs/tiktokClient.spec.js @@ -0,0 +1,35 @@ +import ApiClient from '../ApiClient'; +import tiktokClient from '../channel/tiktokClient'; + +describe('#TiktokClient', () => { + it('creates correct instance', () => { + expect(tiktokClient).toBeInstanceOf(ApiClient); + expect(tiktokClient).toHaveProperty('generateAuthorization'); + }); + + describe('#generateAuthorization', () => { + const originalAxios = window.axios; + const originalPathname = window.location.pathname; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + window.history.pushState({}, '', '/app/accounts/1/settings'); + }); + + afterEach(() => { + window.axios = originalAxios; + window.history.pushState({}, '', originalPathname); + }); + + it('posts to the authorization endpoint', () => { + tiktokClient.generateAuthorization({ state: 'test-state' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/tiktok/authorization', + { state: 'test-state' } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue index e77a0c10d..3dc29738e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue @@ -44,6 +44,7 @@ const SOCIAL_CONFIG = { LINKEDIN: 'i-ri-linkedin-box-fill', FACEBOOK: 'i-ri-facebook-circle-fill', INSTAGRAM: 'i-ri-instagram-line', + TIKTOK: 'i-ri-tiktok-fill', TWITTER: 'i-ri-twitter-x-fill', GITHUB: 'i-ri-github-fill', }; @@ -65,6 +66,7 @@ const defaultState = { facebook: '', github: '', instagram: '', + tiktok: '', linkedin: '', twitter: '', }, diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index a356ae2fd..25b54d125 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -13,6 +13,7 @@ export function useChannelIcon(inbox) { 'Channel::WebWidget': 'i-woot-website', 'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Instagram': 'i-woot-instagram', + 'Channel::Tiktok': 'i-woot-tiktok', 'Channel::Voice': 'i-ri-phone-fill', }; diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js index e32df567d..7f2319b86 100644 --- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js +++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js @@ -61,6 +61,12 @@ describe('useChannelIcon', () => { expect(icon).toBe('i-woot-instagram'); }); + it('returns correct icon for TikTok channel', () => { + const inbox = { channel_type: 'Channel::Tiktok' }; + const { value: icon } = useChannelIcon(inbox); + expect(icon).toBe('i-woot-tiktok'); + }); + describe('TwilioSms channel', () => { it('returns chat icon for regular Twilio SMS channel', () => { const inbox = { channel_type: 'Channel::TwilioSms' }; diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index fabef6bc5..6fa7ff2b2 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -28,6 +28,7 @@ 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'; @@ -317,6 +318,7 @@ const componentToRender = computed(() => { 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 diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue index e0602cb89..e633d7c3c 100644 --- a/app/javascript/dashboard/components-next/message/MessageMeta.vue +++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue @@ -20,6 +20,7 @@ const { isAWhatsAppChannel, isAnEmailChannel, isAnInstagramChannel, + isATiktokChannel, } = useInbox(); const { @@ -60,7 +61,8 @@ const isSent = computed(() => { isAFacebookInbox.value || isASmsInbox.value || isATelegramChannel.value || - isAnInstagramChannel.value + isAnInstagramChannel.value || + isATiktokChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.SENT; } @@ -78,7 +80,8 @@ const isDelivered = computed(() => { isAWhatsAppChannel.value || isATwilioChannel.value || isASmsInbox.value || - isAFacebookInbox.value + isAFacebookInbox.value || + isATiktokChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED; } @@ -100,7 +103,8 @@ const isRead = computed(() => { isAWhatsAppChannel.value || isATwilioChannel.value || isAFacebookInbox.value || - isAnInstagramChannel.value + isAnInstagramChannel.value || + isATiktokChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.READ; } diff --git a/app/javascript/dashboard/components-next/message/bubbles/Embed.vue b/app/javascript/dashboard/components-next/message/bubbles/Embed.vue new file mode 100644 index 000000000..4229e1a89 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/bubbles/Embed.vue @@ -0,0 +1,30 @@ + + +