From cc5974da9bec82fccf7fcba1ed14b7326dc486c8 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 6 May 2026 09:54:00 +0400
Subject: [PATCH 1/5] feat(inbox): Add beta badge for TikTok and Voice channels
(#14378)
TikTok and Voice channels in the inbox creation flow now display a small
"Beta" badge next to their title, signaling that these integrations are
still being polished while keeping them available for users to try.
Fixes
https://linear.app/chatwoot/issue/CW-7026/add-beta-label-for-tiktok-and-voice-inboxes
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context)
---
.../dashboard/components/ChannelSelector.vue | 25 ++++++++++++++++---
.../components/widgets/ChannelItem.vue | 5 ++++
2 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/app/javascript/dashboard/components/ChannelSelector.vue b/app/javascript/dashboard/components/ChannelSelector.vue
index 2e3ea7d86..ef5256577 100644
--- a/app/javascript/dashboard/components/ChannelSelector.vue
+++ b/app/javascript/dashboard/components/ChannelSelector.vue
@@ -1,5 +1,7 @@
@@ -37,9 +45,18 @@ defineProps({
-
- {{ title }}
-
+
+
+ {{ title }}
+
+
+
{{ description }}
@@ -50,7 +67,7 @@ defineProps({
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
>
- {{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
+ {{ t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue
index 31c58da47..2cbad72c5 100644
--- a/app/javascript/dashboard/components/widgets/ChannelItem.vue
+++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue
@@ -77,6 +77,10 @@ const isComingSoon = computed(() => {
return ['voice'].includes(key) && !isActive.value;
});
+const isBeta = computed(() => {
+ return ['tiktok', 'voice'].includes(props.channel.key);
+});
+
const onItemClick = () => {
if (isActive.value) {
emit('channelItemClick', props.channel.key);
@@ -90,6 +94,7 @@ const onItemClick = () => {
:description="channel.description"
:icon="channel.icon"
:is-coming-soon="isComingSoon"
+ :is-beta="isBeta"
:disabled="!isActive"
@click="onItemClick"
/>
From b8108b71c1095e10bed68adfa6933c577cb692a1 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 6 May 2026 11:21:15 +0400
Subject: [PATCH 2/5] fix(tiktok): Resolve media upload failures and gate
attachments by conversation capability (#13643)
This PR fixes TikTok attachment send failures and adds a
capability-based guard so attachments are only enabled for conversations
that support media sending.
- Fixed TikTok media upload request formatting so TikTok accepts image
uploads reliably.
- Added TikTok capability check (IMAGE_SEND) during conversation
creation.
- Stored capability in
conversation.additional_attributes.tiktok_capabilities.
- Updated reply composer UI to disable/hide attachment upload for TikTok
conversations where image_send is false.
Fixes
https://linear.app/chatwoot/issue/CW-6532/enable-attachments-based-on-the-conversation-capability
and
https://linear.app/chatwoot/issue/CW-6996/unable-to-send-image-attachments-to-tiktok-customer-400-parsing-error
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
---
.../widgets/conversation/ReplyBox.vue | 9 +-
app/services/tiktok/client.rb | 55 +++++--
app/services/tiktok/messaging_helpers.rb | 22 ++-
app/services/tiktok/send_on_tiktok_service.rb | 19 ++-
spec/services/tiktok/client_spec.rb | 136 ++++++++++++++++++
spec/services/tiktok/message_service_spec.rb | 57 ++++++--
.../tiktok/send_on_tiktok_service_spec.rb | 87 ++++++++++-
7 files changed, 352 insertions(+), 33 deletions(-)
create mode 100644 spec/services/tiktok/client_spec.rb
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index d18be0caa..872f68640 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -273,6 +273,10 @@ export default {
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
+ const { image_send: imageSend } =
+ this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
+ const tiktokAttachmentSupported = imageSend ?? true;
+
return (
this.isAWebWidgetInbox ||
this.isAFacebookInbox ||
@@ -283,7 +287,7 @@ export default {
this.isATelegramChannel ||
this.isALineChannel ||
this.isAnInstagramChannel ||
- this.isATiktokChannel
+ (this.isATiktokChannel && tiktokAttachmentSupported)
);
},
replyButtonLabel() {
@@ -706,6 +710,7 @@ export default {
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
+ if (!this.showFileUpload && !this.isOnPrivateNote) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
@@ -1025,6 +1030,8 @@ export default {
});
},
attachFile({ blob, file }) {
+ if (!this.showFileUpload && !this.isOnPrivateNote) return;
+
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
diff --git a/app/services/tiktok/client.rb b/app/services/tiktok/client.rb
index dfbe8ba1f..5b112d8d7 100644
--- a/app/services/tiktok/client.rb
+++ b/app/services/tiktok/client.rb
@@ -1,3 +1,6 @@
+require 'faraday'
+require 'faraday/multipart'
+
class Tiktok::Client
# Always use Tiktok::TokenService to get a valid access token
pattr_initialize [:business_id!, :access_token!]
@@ -31,14 +34,32 @@ class Tiktok::Client
json['data']['download_url']
end
+ def image_send_capable?(conversation_id, conversation_type: 'SINGLE')
+ endpoint = "#{api_base_url}/business/message/capabilities/get/"
+ headers = { 'Access-Token': access_token }
+ query = {
+ business_id: business_id,
+ conversation_id: conversation_id,
+ conversation_type: conversation_type,
+ capability_types: ['IMAGE_SEND'].to_json
+ }
+
+ response = HTTParty.get(endpoint, query: query, headers: headers)
+ json = process_json_response(response, 'Failed to fetch TikTok message capabilities')
+ capabilities = json.dig('data', 'capability_infos') || []
+ image_send = capabilities.find { |capability| capability['capability_type'] == 'IMAGE_SEND' }
+
+ image_send&.[]('capability_result') == true
+ end
+
def send_text_message(conversation_id, text, referenced_message_id: nil)
send_message(conversation_id, 'TEXT', text, referenced_message_id: referenced_message_id)
end
- def send_media_message(conversation_id, attachment, referenced_message_id: nil)
+ def send_media_message(conversation_id, attachment)
# As of now, only IMAGE media type is supported
- media_id = upload_media(attachment.file, 'IMAGE')
- send_message(conversation_id, 'IMAGE', media_id, referenced_message_id: referenced_message_id)
+ media_id = upload_media(attachment.file.blob, 'IMAGE')
+ send_message(conversation_id, 'IMAGE', media_id)
end
private
@@ -69,31 +90,39 @@ class Tiktok::Client
json['data']['message']['message_id']
end
- def upload_media(file, media_type = 'IMAGE')
+ def upload_media(blob, media_type = 'IMAGE')
endpoint = "#{api_base_url}/business/message/media/upload/"
- headers = { 'Access-Token': access_token, 'Content-Type': 'multipart/form-data' }
- file.open do |temp_file|
- body = {
+ blob.open do |temp_file|
+ temp_file.rewind
+ payload = {
business_id: business_id,
media_type: media_type,
- file: temp_file
+ file: Faraday::Multipart::FilePart.new(temp_file, blob.content_type || 'application/octet-stream', blob.filename.to_s)
}
- response = HTTParty.post(endpoint, body: body, headers: headers)
+ response = multipart_connection.post(endpoint, payload) do |request|
+ request.headers['Access-Token'] = access_token
+ end
json = process_json_response(response, 'Failed to upload TikTok media')
json['data']['media_id']
end
end
+ def multipart_connection
+ @multipart_connection ||= Faraday.new do |faraday|
+ faraday.request :multipart
+ end
+ end
+
def api_base_url
"https://business-api.tiktok.com/open_api/#{GlobalConfigService.load('TIKTOK_API_VERSION', 'v1.3')}"
end
def process_json_response(response, error_prefix)
unless response.success?
- Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
- raise "#{response.code}: #{response.body}"
+ Rails.logger.error "#{error_prefix}. Status: #{response_status(response)}, Body: #{response.body}"
+ raise "#{response_status(response)}: #{response.body}"
end
res = JSON.parse(response.body)
@@ -101,4 +130,8 @@ class Tiktok::Client
res
end
+
+ def response_status(response)
+ response.respond_to?(:code) ? response.code : response.status
+ end
end
diff --git a/app/services/tiktok/messaging_helpers.rb b/app/services/tiktok/messaging_helpers.rb
index 0f9d9e0b3..ce2aea817 100644
--- a/app/services/tiktok/messaging_helpers.rb
+++ b/app/services/tiktok/messaging_helpers.rb
@@ -48,14 +48,26 @@ module Tiktok::MessagingHelpers
inbox_id: channel.inbox.id,
contact_id: contact_inbox.contact.id,
contact_inbox_id: contact_inbox.id,
- additional_attributes: conversation_additional_attributes(tt_conversation_id)
+ additional_attributes: conversation_additional_attributes(channel, tt_conversation_id)
}
end
- def conversation_additional_attributes(tt_conversation_id)
- {
- conversation_id: tt_conversation_id
- }
+ def conversation_additional_attributes(channel, tt_conversation_id)
+ attributes = { conversation_id: tt_conversation_id }
+ capabilities = tiktok_conversation_capabilities(channel, tt_conversation_id)
+ attributes[:tiktok_capabilities] = capabilities if capabilities.present?
+ attributes
+ end
+
+ def tiktok_conversation_capabilities(channel, tt_conversation_id)
+ image_send = tiktok_client(channel).image_send_capable?(tt_conversation_id)
+ { image_send: image_send, updated_at: Time.current.iso8601 }
+ rescue StandardError => e
+ Rails.logger.error(
+ 'Failed to fetch TikTok conversation capabilities ' \
+ "for tt_conversation_id=#{tt_conversation_id}, business_id=#{channel.business_id}: #{e.class}: #{e.message}"
+ )
+ {}
end
def find_message(tt_conversation_id, tt_message_id)
diff --git a/app/services/tiktok/send_on_tiktok_service.rb b/app/services/tiktok/send_on_tiktok_service.rb
index d7db5f326..58771cd05 100644
--- a/app/services/tiktok/send_on_tiktok_service.rb
+++ b/app/services/tiktok/send_on_tiktok_service.rb
@@ -1,4 +1,7 @@
class Tiktok::SendOnTiktokService < Base::SendOnChannelService
+ SUPPORTED_IMAGE_CONTENT_TYPES = %w[image/jpeg image/png].freeze
+ MAX_IMAGE_SIZE = 3.megabytes
+
private
def channel_class
@@ -18,8 +21,22 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService
def validate_message_support!
return unless message.attachments.any?
+
raise 'Sending attachments with text is not supported on TikTok.' if message.outgoing_content.present?
raise 'Sending multiple attachments in a single TikTok message is not supported.' unless message.attachments.one?
+
+ validate_attachment_support!(message.attachments.first)
+ end
+
+ def validate_attachment_support!(attachment)
+ raise 'Sending image attachments is not supported for this TikTok conversation.' unless image_send_capable?
+ raise 'Only image attachments are supported on TikTok.' unless attachment.image?
+ raise 'TikTok supports only JPG and PNG images.' unless SUPPORTED_IMAGE_CONTENT_TYPES.include?(attachment.file.content_type)
+ raise 'TikTok image attachments must be smaller than 3 MB.' if attachment.file.byte_size > MAX_IMAGE_SIZE
+ end
+
+ def image_send_capable?
+ message.conversation.additional_attributes.dig('tiktok_capabilities', 'image_send') != false
end
def send_message
@@ -27,7 +44,7 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService
tt_referenced_message_id = message.content_attributes['in_reply_to_external_id']
if message.attachments.any?
- tiktok_client.send_media_message(tt_conversation_id, message.attachments.first, referenced_message_id: tt_referenced_message_id)
+ tiktok_client.send_media_message(tt_conversation_id, message.attachments.first)
else
tiktok_client.send_text_message(tt_conversation_id, message.outgoing_content, referenced_message_id: tt_referenced_message_id)
end
diff --git a/spec/services/tiktok/client_spec.rb b/spec/services/tiktok/client_spec.rb
new file mode 100644
index 000000000..9bf02be91
--- /dev/null
+++ b/spec/services/tiktok/client_spec.rb
@@ -0,0 +1,136 @@
+require 'rails_helper'
+
+RSpec.describe Tiktok::Client do
+ let(:client) { described_class.new(business_id: 'biz-123', access_token: 'token-123') }
+ let(:response) { instance_double(HTTParty::Response) }
+
+ describe '#image_send_capable?' do
+ before do
+ allow(HTTParty).to receive(:get).and_return(response)
+ allow(GlobalConfigService).to receive(:load).with('TIKTOK_API_VERSION', 'v1.3').and_return('v1.3')
+ end
+
+ it 'returns true when IMAGE_SEND capability is enabled' do
+ allow(client).to receive(:process_json_response).with(
+ response,
+ 'Failed to fetch TikTok message capabilities'
+ ).and_return(
+ {
+ 'data' => {
+ 'capability_infos' => [
+ { 'capability_type' => 'IMAGE_SEND', 'capability_result' => true }
+ ]
+ }
+ }
+ )
+
+ result = client.image_send_capable?('tt-conv-1')
+
+ expect(result).to be(true)
+ expect(HTTParty).to have_received(:get).with(
+ 'https://business-api.tiktok.com/open_api/v1.3/business/message/capabilities/get/',
+ query: {
+ business_id: 'biz-123',
+ conversation_id: 'tt-conv-1',
+ conversation_type: 'SINGLE',
+ capability_types: '["IMAGE_SEND"]'
+ },
+ headers: { 'Access-Token': 'token-123' }
+ )
+ end
+
+ it 'returns false when IMAGE_SEND capability is not enabled' do
+ allow(client).to receive(:process_json_response).with(
+ response,
+ 'Failed to fetch TikTok message capabilities'
+ ).and_return(
+ {
+ 'data' => {
+ 'capability_infos' => [
+ { 'capability_type' => 'IMAGE_SEND', 'capability_result' => false }
+ ]
+ }
+ }
+ )
+
+ result = client.image_send_capable?('tt-conv-1')
+
+ expect(result).to be(false)
+ end
+ end
+
+ describe '#upload_media' do
+ let(:connection) { instance_double(Faraday::Connection) }
+ let(:request) { instance_double(Faraday::Request, headers: {}) }
+ let(:response) { instance_double(Faraday::Response, success?: true, body: response_body) }
+ let(:response_body) do
+ {
+ code: 0,
+ message: 'OK',
+ data: { media_id: 'media-123' }
+ }.to_json
+ end
+ let(:blob) do
+ instance_double(
+ ActiveStorage::Blob,
+ content_type: 'image/png',
+ filename: ActiveStorage::Filename.new('avatar.png')
+ )
+ end
+
+ before do
+ allow(GlobalConfigService).to receive(:load).with('TIKTOK_API_VERSION', 'v1.3').and_return('v1.3')
+ allow(Faraday).to receive(:new).and_return(connection)
+ allow(blob).to receive(:open) do |&block|
+ File.open(Rails.root.join('spec/assets/avatar.png'), 'rb', &block)
+ end
+ end
+
+ it 'posts media upload with access token header' do
+ captured_endpoint = nil
+ allow(connection).to receive(:post) do |endpoint, _payload, &block|
+ captured_endpoint = endpoint
+ block.call(request)
+ response
+ end
+
+ media_id = client.send(:upload_media, blob)
+
+ expect(media_id).to eq('media-123')
+ expect(captured_endpoint).to eq('https://business-api.tiktok.com/open_api/v1.3/business/message/media/upload/')
+ expect(request.headers['Access-Token']).to eq('token-123')
+ end
+
+ it 'uploads media as a multipart file with filename and content type' do
+ captured_payload = nil
+ allow(connection).to receive(:post) do |_endpoint, payload, &block|
+ captured_payload = payload
+ block.call(request)
+ response
+ end
+
+ client.send(:upload_media, blob)
+
+ expect(captured_payload[:business_id]).to eq('biz-123')
+ expect(captured_payload[:media_type]).to eq('IMAGE')
+ expect(captured_payload[:file]).to be_a(Faraday::Multipart::FilePart)
+ expect(captured_payload[:file].content_type).to eq('image/png')
+ expect(captured_payload[:file].original_filename).to eq('avatar.png')
+ end
+ end
+
+ describe '#send_media_message' do
+ let(:file) { Struct.new(:blob).new('blob') }
+ let(:attachment) { instance_double(Attachment, file: file) }
+
+ it 'sends image messages' do
+ allow(client).to receive(:upload_media).with('blob', 'IMAGE').and_return('media-123')
+ allow(client).to receive(:send_message).and_return('tt-msg-123')
+
+ message_id = client.send_media_message('tt-conv-1', attachment)
+
+ expect(message_id).to eq('tt-msg-123')
+ expect(client).to have_received(:send_message).with('tt-conv-1', 'IMAGE', 'media-123')
+ end
+ end
+end
diff --git a/spec/services/tiktok/message_service_spec.rb b/spec/services/tiktok/message_service_spec.rb
index ee7b113ac..75878fe9f 100644
--- a/spec/services/tiktok/message_service_spec.rb
+++ b/spec/services/tiktok/message_service_spec.rb
@@ -6,10 +6,11 @@ RSpec.describe Tiktok::MessageService do
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
+ let(:tiktok_client) { instance_double(Tiktok::Client, image_send_capable?: true) }
let(:text_content) do
{
type: 'text',
- message_id: 'tt-msg-lock',
+ message_id: 'tt-msg-1',
timestamp: 1_700_000_000_000,
conversation_id: 'tt-conv-1',
text: { body: 'Hello from TikTok' },
@@ -20,6 +21,10 @@ RSpec.describe Tiktok::MessageService do
}.deep_symbolize_keys
end
+ before do
+ allow(Tiktok::Client).to receive(:new).and_return(tiktok_client)
+ end
+
describe '#perform' do
subject(:perform_text_message) do
service = described_class.new(channel: channel, content: current_content)
@@ -30,20 +35,8 @@ RSpec.describe Tiktok::MessageService do
let(:current_content) { text_content }
it 'creates an incoming text message' do
- content = {
- type: 'text',
- message_id: 'tt-msg-1',
- timestamp: 1_700_000_000_000,
- conversation_id: 'tt-conv-1',
- text: { body: 'Hello from TikTok' },
- from: 'Alice',
- from_user: { id: 'user-1' },
- to: 'Biz',
- to_user: { id: 'biz-123' }
- }.deep_symbolize_keys
-
expect do
- service = described_class.new(channel: channel, content: content)
+ service = described_class.new(channel: channel, content: text_content)
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
service.perform
end.to change(Message, :count).by(1)
@@ -57,6 +50,18 @@ RSpec.describe Tiktok::MessageService do
expect(message.content_attributes['is_unsupported']).to be_nil
end
+ it 'stores TikTok conversation capabilities when creating a new conversation' do
+ service = described_class.new(channel: channel, content: text_content)
+ allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
+
+ service.perform
+
+ message = Message.last
+ expect(message.conversation.additional_attributes.dig('tiktok_capabilities', 'image_send')).to be(true)
+ expect(message.conversation.additional_attributes.dig('tiktok_capabilities', 'updated_at')).to be_present
+ expect(tiktok_client).to have_received(:image_send_capable?).with('tt-conv-1')
+ end
+
it 'creates an incoming unsupported message for non-supported types' do
content = {
type: 'sticker',
@@ -135,6 +140,30 @@ RSpec.describe Tiktok::MessageService do
tempfile.close!
end
+ it 'creates a conversation even when capability lookup fails' do
+ allow(tiktok_client).to receive(:image_send_capable?).and_raise('TikTok capability API error')
+
+ content = {
+ type: 'text',
+ message_id: 'tt-msg-5',
+ timestamp: 1_700_000_000_000,
+ conversation_id: 'tt-conv-1',
+ text: { body: 'Hello with capability failure' },
+ from: 'Alice',
+ from_user: { id: 'user-1' },
+ to: 'Biz',
+ to_user: { id: 'biz-123' }
+ }.deep_symbolize_keys
+
+ service = described_class.new(channel: channel, content: content)
+ allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
+
+ expect { service.perform }.to change(Message, :count).by(1)
+
+ message = Message.last
+ expect(message.conversation.additional_attributes['tiktok_capabilities']).to be_nil
+ end
+
context 'when lock_to_single_conversation is enabled' do
it 'reuses the last resolved conversation' do
inbox.update!(lock_to_single_conversation: true)
diff --git a/spec/services/tiktok/send_on_tiktok_service_spec.rb b/spec/services/tiktok/send_on_tiktok_service_spec.rb
index 0543fa695..2ca1ffd03 100644
--- a/spec/services/tiktok/send_on_tiktok_service_spec.rb
+++ b/spec/services/tiktok/send_on_tiktok_service_spec.rb
@@ -48,10 +48,33 @@ RSpec.describe Tiktok::SendOnTiktokService do
described_class.new(message: message).perform
- expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first, referenced_message_id: nil)
+ expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first)
expect(message.reload.source_id).to eq('tt-msg-124')
end
+ it 'sends outgoing image message without quote metadata' do
+ allow(tiktok_client).to receive(:send_media_message).and_return('tt-msg-124')
+ allow(tiktok_client).to receive(:send_text_message)
+
+ message = build(
+ :message,
+ message_type: :outgoing,
+ inbox: inbox,
+ conversation: conversation,
+ account: inbox.account,
+ content: nil,
+ content_attributes: { in_reply_to_external_id: 'quoted-message-id' }
+ )
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ message.save!
+
+ described_class.new(message: message).perform
+
+ expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first)
+ expect(tiktok_client).not_to have_received(:send_text_message)
+ end
+
it 'marks message as failed when sending multiple attachments' do
allow(tiktok_client).to receive(:send_media_message)
@@ -67,5 +90,67 @@ RSpec.describe Tiktok::SendOnTiktokService do
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', kind_of(String))
expect(tiktok_client).not_to have_received(:send_media_message)
end
+
+ it 'marks message as failed when conversation cannot send images' do
+ allow(tiktok_client).to receive(:send_media_message)
+ conversation.update!(additional_attributes: { conversation_id: 'tt-conv-1', tiktok_capabilities: { image_send: false } })
+
+ message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ message.save!
+
+ described_class.new(message: message).perform
+
+ expect(Messages::StatusUpdateService).to have_received(:new).with(
+ message,
+ 'failed',
+ 'Sending image attachments is not supported for this TikTok conversation.'
+ )
+ expect(tiktok_client).not_to have_received(:send_media_message)
+ end
+
+ it 'marks message as failed when attachment is not an image' do
+ allow(tiktok_client).to receive(:send_media_message)
+
+ message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
+ attachment.file.attach(io: Rails.root.join('spec/assets/contacts.csv').open, filename: 'contacts.csv', content_type: 'text/csv')
+ message.save!
+
+ described_class.new(message: message).perform
+
+ expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'Only image attachments are supported on TikTok.')
+ expect(tiktok_client).not_to have_received(:send_media_message)
+ end
+
+ it 'marks message as failed when image format is unsupported' do
+ allow(tiktok_client).to receive(:send_media_message)
+
+ message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/contacts.csv').open, filename: 'contacts.csv', content_type: 'text/csv')
+ message.save!
+
+ described_class.new(message: message).perform
+
+ expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'TikTok supports only JPG and PNG images.')
+ expect(tiktok_client).not_to have_received(:send_media_message)
+ end
+
+ it 'marks message as failed when image is larger than 3 MB' do
+ allow(tiktok_client).to receive(:send_media_message)
+
+ message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ message.save!
+ allow(message.attachments.first.file).to receive(:byte_size).and_return(4.megabytes)
+
+ described_class.new(message: message).perform
+
+ expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'TikTok image attachments must be smaller than 3 MB.')
+ expect(tiktok_client).not_to have_received(:send_media_message)
+ end
end
end
From 00ba46848615dba4baed0b263f7575f84dbca5b8 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Wed, 6 May 2026 15:10:11 +0530
Subject: [PATCH 3/5] fix(macros): disable public visibility for agents
(#14349)
---
.../dashboard/i18n/locale/en/macros.json | 7 +-
.../dashboard/settings/macros/Index.vue | 3 +
.../dashboard/settings/macros/MacroEditor.vue | 11 ++-
.../dashboard/settings/macros/MacroForm.vue | 28 +++++--
.../settings/macros/MacroProperties.vue | 53 ++++++++++++-
.../settings/macros/MacrosTableRow.vue | 15 +++-
.../macros/specs/MacroProperties.spec.js | 79 +++++++++++++++++++
.../macros/specs/MacrosTableRow.spec.js | 63 +++++++++++++++
app/policies/macro_policy.rb | 14 ++--
.../api/v1/accounts/macros_controller_spec.rb | 31 ++++++++
10 files changed, 281 insertions(+), 23 deletions(-)
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacroProperties.spec.js
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
diff --git a/app/javascript/dashboard/i18n/locale/en/macros.json b/app/javascript/dashboard/i18n/locale/en/macros.json
index 67a5c5003..e51975921 100644
--- a/app/javascript/dashboard/i18n/locale/en/macros.json
+++ b/app/javascript/dashboard/i18n/locale/en/macros.json
@@ -49,6 +49,9 @@
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
+ "VIEW": {
+ "TOOLTIP": "View macro"
+ },
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
@@ -66,7 +69,9 @@
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
- "DESCRIPTION": "This macro is available publicly for all agents in this account."
+ "DESCRIPTION": "This macro is available publicly for all agents in this account.",
+ "CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
+ "EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
},
"PERSONAL": {
"LABEL": "Private",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
index 2ff6122b7..b79d78e30 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
@@ -9,10 +9,12 @@ import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
+import { useAdmin } from 'dashboard/composables/useAdmin';
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
+const { isAdmin } = useAdmin();
const showDeleteConfirmationPopup = ref(false);
const selectedMacro = ref({});
@@ -109,6 +111,7 @@ const tableHeaders = computed(() => {
v-for="macro in items"
:key="macro.id"
:macro="macro"
+ :can-manage-public-macros="isAdmin"
@delete="openDeletePopup(macro)"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
index 0b916a4ab..b941598fe 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
@@ -8,6 +8,7 @@ import { MACRO_ACTION_TYPES } from './constants';
import { useAlert } from 'dashboard/composables';
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
import { useMacros } from 'dashboard/composables/useMacros';
+import { useAdmin } from 'dashboard/composables/useAdmin';
const store = useStore();
const getters = useStoreGetters();
@@ -18,6 +19,7 @@ const router = useRouter();
const { t } = useI18n();
const { getMacroDropdownValues } = useMacros();
+const { isAdmin } = useAdmin();
const macro = ref(null);
const mode = ref('CREATE');
@@ -33,6 +35,9 @@ provide('macroActionTypes', macroActionTypes);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const macroId = computed(() => route.params.macroId);
+const isPublicMacroReadOnly = computed(
+ () => macro.value?.visibility === 'global' && !isAdmin.value
+);
const fetchDropdownData = () => {
store.dispatch('agents/get');
@@ -92,7 +97,7 @@ const initNewMacro = () => {
action_params: [],
},
],
- visibility: 'global',
+ visibility: isAdmin.value ? 'global' : 'personal',
};
};
@@ -110,6 +115,8 @@ watch(
);
const saveMacro = async macroData => {
+ if (isPublicMacroReadOnly.value) return;
+
try {
const action = mode.value === 'EDIT' ? 'macros/update' : 'macros/create';
const successMessage =
@@ -136,6 +143,8 @@ const saveMacro = async macroData => {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
index 432b0d46b..b8b00aac9 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
@@ -16,6 +16,14 @@ export default {
type: Object,
default: () => ({}),
},
+ canManagePublicMacros: {
+ type: Boolean,
+ default: true,
+ },
+ readOnly: {
+ type: Boolean,
+ default: false,
+ },
},
emits: ['submit'],
setup() {
@@ -112,19 +120,23 @@ export default {
@@ -55,8 +89,13 @@ export default {
@@ -69,13 +108,18 @@ export default {
class="text-n-brand size-4"
/>
-
- {{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
+
+ {{ publicVisibilityDescription }}
@@ -111,6 +155,7 @@ export default {
solid
:label="$t('MACROS.HEADER_BTN_TXT_SAVE')"
class="w-full"
+ :disabled="readOnly"
@click="$emit('submit')"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
index 934a451de..f7edcb512 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
@@ -11,6 +11,10 @@ const props = defineProps({
type: Object,
required: true,
},
+ canManagePublicMacros: {
+ type: Boolean,
+ default: true,
+ },
});
defineEmits(['delete']);
const { t } = useI18n();
@@ -32,6 +36,14 @@ const visibilityLabel = computed(() => {
: 'MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL';
return t(i18nKey);
});
+
+const canManageMacro = computed(
+ () => props.canManagePublicMacros || props.macro.visibility !== 'global'
+);
+
+const editTooltip = computed(() =>
+ canManageMacro.value ? t('MACROS.EDIT.TOOLTIP') : t('MACROS.VIEW.TOOLTIP')
+);
@@ -85,13 +97,14 @@ const visibilityLabel = computed(() => {
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
>
+ shallowMount(MacroProperties, {
+ props: {
+ macroName: 'Close conversation',
+ macroVisibility: 'personal',
+ ...props,
+ },
+ global: {
+ provide: {
+ v$: {
+ macro: {
+ name: {
+ $error: false,
+ },
+ },
+ },
+ },
+ stubs: {
+ WootInput: true,
+ NextButton: true,
+ Icon: true,
+ },
+ },
+ });
+
+describe('MacroProperties.vue', () => {
+ it('allows administrators to select public visibility', async () => {
+ const wrapper = mountComponent({ canManagePublicMacros: true });
+ const publicButton = wrapper.findAll('button')[0];
+
+ await publicButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeUndefined();
+ expect(wrapper.emitted('update:visibility')?.[0]).toEqual(['global']);
+ });
+
+ it('disables public visibility for agents with helper copy', async () => {
+ const wrapper = mountComponent({ canManagePublicMacros: false });
+ const publicButton = wrapper.findAll('button')[0];
+
+ await publicButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeDefined();
+ expect(wrapper.emitted('update:visibility')).toBeUndefined();
+ expect(wrapper.text()).toContain(
+ 'Only administrators can create public macros.'
+ );
+ });
+
+ it('keeps existing public macros visibly selected when public is disabled', () => {
+ const wrapper = mountComponent({
+ canManagePublicMacros: false,
+ macroVisibility: 'global',
+ });
+
+ expect(wrapper.findComponent({ name: 'Icon' }).exists()).toBe(true);
+ });
+
+ it('shows existing public macros as read-only for agents', async () => {
+ const wrapper = mountComponent({
+ canManagePublicMacros: false,
+ macroVisibility: 'global',
+ readOnly: true,
+ });
+ const [publicButton, privateButton] = wrapper.findAll('button');
+
+ await privateButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeDefined();
+ expect(privateButton.attributes('disabled')).toBeDefined();
+ expect(wrapper.emitted('update:visibility')).toBeUndefined();
+ expect(wrapper.text()).toContain(
+ 'Only administrators can edit public macros.'
+ );
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js b/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
new file mode 100644
index 000000000..268a54240
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
@@ -0,0 +1,63 @@
+import { shallowMount } from '@vue/test-utils';
+import MacrosTableRow from '../MacrosTableRow.vue';
+
+const macro = visibility => ({
+ id: 1,
+ name: 'Close conversation',
+ visibility,
+ created_by: {
+ available_name: 'Maya Chen',
+ email: 'maya.chen@example.com',
+ },
+ updated_by: {
+ available_name: 'Maya Chen',
+ email: 'maya.chen@example.com',
+ },
+});
+
+const mountComponent = props =>
+ shallowMount(MacrosTableRow, {
+ props: {
+ macro: macro('global'),
+ canManagePublicMacros: true,
+ ...props,
+ },
+ global: {
+ stubs: {
+ Avatar: true,
+ BaseTableRow: {
+ template: '
',
+ },
+ BaseTableCell: {
+ template: '
',
+ },
+ Button: true,
+ RouterLink: {
+ template: ' ',
+ },
+ },
+ },
+ });
+
+describe('MacrosTableRow.vue', () => {
+ it('shows actions for public macros when public macros can be managed', () => {
+ const wrapper = mountComponent();
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
+ });
+
+ it('keeps public macros viewable without delete actions when public macros cannot be managed', () => {
+ const wrapper = mountComponent({ canManagePublicMacros: false });
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(1);
+ });
+
+ it('keeps actions available for personal macros when public macros cannot be managed', () => {
+ const wrapper = mountComponent({
+ macro: macro('personal'),
+ canManagePublicMacros: false,
+ });
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
+ });
+});
diff --git a/app/policies/macro_policy.rb b/app/policies/macro_policy.rb
index 5d7af755b..d70b688a8 100644
--- a/app/policies/macro_policy.rb
+++ b/app/policies/macro_policy.rb
@@ -12,11 +12,15 @@ class MacroPolicy < ApplicationPolicy
end
def update?
- author? || (@account_user.administrator? && @record.global?)
+ return @account_user.administrator? if @record.global?
+
+ author?
end
def destroy?
- author? || orphan_record?
+ return @account_user.administrator? if @record.global?
+
+ author?
end
def execute?
@@ -28,10 +32,4 @@ class MacroPolicy < ApplicationPolicy
def author?
@record.created_by == @account_user.user
end
-
- def orphan_record?
- return @account_user.administrator? if @record.created_by.nil? && @record.global?
-
- false
- end
end
diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb
index 1af908e11..1501309a0 100644
--- a/spec/controllers/api/v1/accounts/macros_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb
@@ -239,6 +239,22 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(json_response['error']).to eq('You are not authorized to do this action')
end
+ # A public macro can still point to an agent when an admin who authored it
+ # is later changed to the agent role. Public macros should remain
+ # admin-managed even when the original author is no longer an admin.
+ it 'does not allow agents to update public macros they created' do
+ macro = create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global)
+
+ put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
+ params: params,
+ headers: agent.create_new_auth_token
+
+ json_response = response.parsed_body
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(json_response['error']).to eq('You are not authorized to do this action')
+ end
+
it 'allows update with existing blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
@@ -551,6 +567,21 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(json_response['error']).to eq('You are not authorized to do this action')
end
+ # A public macro can still point to an agent when an admin who authored it
+ # is later changed to the agent role. Public macros should remain
+ # admin-managed even when the original author is no longer an admin.
+ it 'does not allow agents to delete public macros they created' do
+ macro = create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global)
+
+ delete "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
+ headers: agent.create_new_auth_token
+
+ json_response = response.parsed_body
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(json_response['error']).to eq('You are not authorized to do this action')
+ end
+
it 'Unauthorize to delete the macro' do
macro = create(:macro, account: account, created_by: agent, updated_by: agent)
From 8f532f45ff77382bc65672f85e10f73878f5150c Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Wed, 6 May 2026 15:13:51 +0530
Subject: [PATCH 4/5] feat: add attachments section to conversation sidebar
(#14371)
---
.../conversation/components/GalleryView.vue | 6 +
.../dashboard/composables/useUISettings.js | 1 +
.../i18n/locale/en/conversation.json | 14 +-
.../dashboard/conversation/ContactPanel.vue | 13 +
.../dashboard/conversation/SharedFiles.vue | 421 ++++++++++++++++++
.../store/modules/conversations/getters.js | 2 +
.../specs/conversations/getters.spec.js | 25 ++
.../conversations/attachments.json.jbuilder | 1 +
.../accounts/conversations_controller_spec.rb | 2 +
9 files changed, 484 insertions(+), 1 deletion(-)
create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
diff --git a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
index aa17dabf9..6b356f4d3 100644
--- a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
@@ -22,6 +22,10 @@ const props = defineProps({
type: Array,
required: true,
},
+ autoPlay: {
+ type: Boolean,
+ default: false,
+ },
});
const emit = defineEmits(['close']);
@@ -309,6 +313,7 @@ onMounted(() => {
:src="activeAttachment.data_url"
controls
playsInline
+ :autoplay="autoPlay"
class="max-h-full max-w-full object-contain"
@click.stop
/>
@@ -317,6 +322,7 @@ onMounted(() => {
v-if="isAudio"
:key="activeAttachment.message_id"
controls
+ :autoplay="autoPlay"
class="w-full max-w-md"
@click.stop
>
diff --git a/app/javascript/dashboard/composables/useUISettings.js b/app/javascript/dashboard/composables/useUISettings.js
index 9f1943414..4f015e1d8 100644
--- a/app/javascript/dashboard/composables/useUISettings.js
+++ b/app/javascript/dashboard/composables/useUISettings.js
@@ -7,6 +7,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_info' },
{ name: 'contact_attributes' },
{ name: 'contact_notes' },
+ { name: 'shared_files' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 88e66ebe3..2dc3ed70e 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -365,7 +365,19 @@
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"LINEAR_ISSUES": "Linked Linear Issues",
- "SHOPIFY_ORDERS": "Shopify Orders"
+ "SHOPIFY_ORDERS": "Shopify Orders",
+ "SHARED_FILES": "Attachments"
+ },
+ "SHARED_FILES": {
+ "EMPTY": "No attachments yet",
+ "DOWNLOAD": "Download file",
+ "DOWNLOAD_ERROR": "Could not download the file. Please try again.",
+ "MEDIA_HEADING": "Media",
+ "FILES_HEADING": "Files",
+ "VIEW_ALL": "View all",
+ "SHOW_LESS": "Show less",
+ "MORE_COUNT": "+{count}",
+ "UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
index 653b43840..fe95f9e99 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
@@ -17,6 +17,7 @@ import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
+import SharedFiles from './SharedFiles.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
@@ -297,6 +298,18 @@ onMounted(() => {
+
+
toggleSidebarUIState('is_shared_files_open', value)
+ "
+ >
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue b/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
new file mode 100644
index 000000000..272407a53
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
@@ -0,0 +1,421 @@
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
+
+ {{ mediaAttachments.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayDuration(attachment) }}
+
+
+
+ {{ displayTime(attachment) }}
+
+
+
+
+
+
+
+
+
+ {{
+ t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
+ count: mediaOverflow,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
+
+ {{ fileAttachments.length }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js
index 333009707..5e85c423c 100644
--- a/app/javascript/dashboard/store/modules/conversations/getters.js
+++ b/app/javascript/dashboard/store/modules/conversations/getters.js
@@ -57,6 +57,8 @@ const getters = {
getSelectedChatAttachments: ({ selectedChatId, attachments }) => {
return attachments[selectedChatId] || [];
},
+ getSelectedChatAttachmentsLoaded: ({ selectedChatId, attachments }) =>
+ selectedChatId !== null && attachments[selectedChatId] !== undefined,
getChatListFilters: ({ conversationFilters }) => conversationFilters,
getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat;
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
index 69bf9c2ac..a7ee3833e 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
@@ -328,6 +328,31 @@ describe('#getters', () => {
});
});
+ describe('#getSelectedChatAttachmentsLoaded', () => {
+ it('returns true when attachments have been fetched for the selected chat', () => {
+ const state = { selectedChatId: 1, attachments: { 1: [] } };
+ expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(true);
+ });
+
+ it('returns true when the fetched attachment list is non-empty', () => {
+ const state = {
+ selectedChatId: 1,
+ attachments: { 1: [{ id: 1, file_name: 'test' }] },
+ };
+ expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(true);
+ });
+
+ it('returns false when attachments have not been fetched yet', () => {
+ const state = { selectedChatId: 1, attachments: {} };
+ expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(false);
+ });
+
+ it('returns false when no chat is selected', () => {
+ const state = { selectedChatId: null, attachments: {} };
+ expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(false);
+ });
+ });
+
describe('#getContextMenuChatId', () => {
it('returns the context menu chat id', () => {
const state = { contextMenuChatId: 1 };
diff --git a/app/views/api/v1/accounts/conversations/attachments.json.jbuilder b/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
index 167b18390..8bd647f27 100644
--- a/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
+++ b/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
@@ -3,6 +3,7 @@ json.meta do
end
json.payload @attachments do |attachment|
+ json.id attachment.push_event_data[:id]
json.message_id attachment.push_event_data[:message_id]
json.thumb_url attachment.push_event_data[:thumb_url]
json.data_url attachment.push_event_data[:data_url]
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index e007f4900..19d080b47 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -1039,6 +1039,8 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
+ attachment = conversation.messages.last.attachments.first
+ expect(response_body['payload'].first['id']).to eq(attachment.id)
expect(response_body['payload'].first['file_type']).to eq('image')
expect(response_body['payload'].first['sender']['id']).to eq(conversation.messages.last.sender.id)
end
From 9c8cfc40b6380be66e3e1c426a84ff8b1b21342e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 15:14:54 +0530
Subject: [PATCH 5/5] chore(deps): bump dompurify from 3.3.2 to 3.4.0 (#14074)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.2 to
3.4.0.
Release notes
Sourced from dompurify's
releases .
DOMPurify 3.4.0
Most relevant changes:
Fixed a problem with FORBID_TAGS not winning over
ADD_TAGS, thanks @​kodareef5
Fixed several minor problems and typos regarding MathML attributes,
thanks @​DavidOliver
Fixed ADD_ATTR/ADD_TAGS function leaking
into subsequent array-based calls, thanks @​1Jesper1
Fixed a missing SAFE_FOR_TEMPLATES scrub in
RETURN_DOM path, thanks @​bencalif
Fixed a prototype pollution via
CUSTOM_ELEMENT_HANDLING, thanks @​trace37labs
Fixed an issue with ADD_TAGS function form bypassing
FORBID_TAGS, thanks @​eddieran
Fixed an issue with ADD_ATTR predicates skipping URI
validation, thanks @​christos-eth
Fixed an issue with USE_PROFILES prototype pollution,
thanks @​christos-eth
Fixed an issue leading to possible mXSS via Re-Contextualization,
thanks @​researchatfluidattacks
and others
Fixed an issue with closing tags leading to possible mXSS, thanks @​frevadiscor
Fixed a problem with the type dentition patcher after Node version
bump
Fixed freezing BS runs by reducing the tested browsers array
Bumped several dependencies where possible
Added needed files for OpenSSF scorecard checks
Published Advisories are here:
https://github.com/cure53/DOMPurify/security/advisories?state=published
DOMPurify 3.3.3
Fixed an engine requirement for Node 20 which caused hiccups, thanks
@​Rotzbua
Commits
5b16e0b
Getting 3.x branch ready for 3.4.0 release (#1250 )
8bcbf73
chore: Preparing 3.3.3 release
5faddd6
fix: engine requirement (#1210 )
0f91e3a
Update README.md
d5ff1a8
Merge branch 'main' of github.com:cure53/DOMPurify
c3efd48
fix: moved back from jsdom 28 to jsdom 20
988b888
fix: moved back from jsdom 28 to jsdom 20
2726c74
chore: Preparing 3.3.2 release
6202c7e
build(deps): bump @​tootallnate/once and jsdom (#1204 )
302b51d
fix: Expanded the regex ever so slightly to also cover script
Additional commits viewable in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
---
package.json | 2 +-
pnpm-lock.yaml | 13 ++++++-------
2 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/package.json b/package.json
index bb5b59ba3..35e09cfbc 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,7 @@
"countries-and-timezones": "^3.6.0",
"date-fns": "2.21.1",
"date-fns-tz": "^1.3.3",
- "dompurify": "3.3.2",
+ "dompurify": "3.4.0",
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2a3aee897..d76d0a061 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -128,8 +128,8 @@ importers:
specifier: ^1.3.3
version: 1.3.8(date-fns@2.21.1)
dompurify:
- specifier: 3.3.2
- version: 3.3.2
+ specifier: 3.4.0
+ version: 3.4.0
flag-icons:
specifier: ^7.2.3
version: 7.2.3
@@ -2194,9 +2194,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
- dompurify@3.3.2:
- resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
- engines: {node: '>=20'}
+ dompurify@3.4.0:
+ resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@@ -6861,7 +6860,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
- dompurify@3.3.2:
+ dompurify@3.4.0:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -9656,7 +9655,7 @@ snapshots:
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
dependencies:
- dompurify: 3.3.2
+ dompurify: 3.4.0
vue: 3.5.12(typescript@5.6.2)
vue-eslint-parser@9.4.3(eslint@8.57.0):