Merge branch 'develop' into fix/next-message-bubbles

This commit is contained in:
Sivin Varghese
2025-02-11 22:52:44 +05:30
committed by GitHub
23 changed files with 205 additions and 91 deletions
+1 -2
View File
@@ -5,8 +5,7 @@ module PortalHelper
end
def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
"url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
generate_portal_bg_color(portal_color, theme)
end
def generate_gradient_to_bottom(theme)
@@ -10,6 +10,7 @@ defineProps({
iconBgColor: { type: String, default: 'bg-n-alpha-3' },
senderTranslationKey: { type: String, required: true },
content: { type: String, required: true },
title: { type: String, default: '' }, // Title can be any name, description, etc
action: {
type: Object,
required: true,
@@ -48,6 +49,9 @@ const senderName = computed(() => {
}}
</div>
<slot>
<div v-if="title" class="truncate text-sm text-n-slate-12">
{{ title }}
</div>
<div v-if="content" class="truncate text-sm text-n-slate-11">
{{ content }}
</div>
@@ -11,7 +11,7 @@ import {
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
const { content, attachments } = useMessageContext();
const { attachments } = useMessageContext();
const $store = useStore();
const { t } = useI18n();
@@ -24,6 +24,12 @@ const phoneNumber = computed(() => {
return attachment.value.fallbackTitle;
});
const contactName = computed(() => {
const { meta } = attachment.value ?? {};
const { firstName, lastName } = meta ?? {};
return `${firstName ?? ''} ${lastName ?? ''}`.trim();
});
const formattedPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, '');
});
@@ -32,13 +38,9 @@ const rawPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\D/g, '');
});
const name = computed(() => {
return content.value;
});
function getContactObject() {
const contactItem = {
name: name.value,
name: contactName.value,
phone_number: `+${rawPhoneNumber.value}`,
};
return contactItem;
@@ -99,6 +101,7 @@ const action = computed(() => ({
icon="i-teenyicons-user-circle-solid"
icon-bg-color="bg-[#D6409F]"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT"
:title="contactName"
:content="phoneNumber"
:action="formattedPhoneNumber ? action : null"
/>
@@ -57,7 +57,7 @@ useEventListener(document.body, 'mouseup', onMouseUp);
useEventListener(document, 'keydown', onKeydown);
onMounted(() => {
if (onClose && typeof onClose === 'function') {
if (import.meta.env.DEV && onClose && typeof onClose === 'function') {
// eslint-disable-next-line no-console
console.warn(
"[DEPRECATED] The 'onClose' prop is deprecated. Please use the 'close' event instead."
@@ -28,10 +28,12 @@ export default {
},
},
created() {
// eslint-disable-next-line
console.warn(
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
);
if (import.meta.env.DEV) {
// eslint-disable-next-line
console.warn(
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
);
}
},
};
</script>
@@ -30,7 +30,7 @@ import {
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce } from '@chatwoot/utils';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
@@ -388,7 +388,6 @@ export default {
watch: {
currentChat(conversation) {
const { can_reply: canReply } = conversation;
this.setCCAndToEmailsFromLastChat();
if (this.isOnPrivateNote) {
@@ -403,6 +402,19 @@ export default {
this.fetchAndSetReplyTo();
},
// When moving from one conversation to another, the store may not have the
// list of all the messages. A fetch is subsequently made to get the messages.
// However, this update does not trigger the `currentChat` watcher.
// We can add a deep watcher to it, but then, that would be too broad of a net to cast
// And would impact performance too. So we watch the messages directly.
// The watcher here is `deep` too, because the messages array is mutated and
// not replaced. So, a shallow watcher would not catch the change.
'currentChat.messages': {
handler() {
this.setCCAndToEmailsFromLastChat();
},
deep: true,
},
conversationIdByRoute(conversationId, oldConversationId) {
if (conversationId !== oldConversationId) {
this.setToDraft(oldConversationId, this.replyType);
@@ -989,45 +1001,20 @@ export default {
this.ccEmails = value.ccEmails;
},
setCCAndToEmailsFromLastChat() {
if (!this.lastEmail) return;
const {
content_attributes: { email: emailAttributes = {} },
} = this.lastEmail;
// Retrieve the email of the current conversation's sender
const conversationContact = this.currentChat?.meta?.sender?.email || '';
let cc = emailAttributes.cc ? [...emailAttributes.cc] : [];
let to = [];
const { email: inboxEmail, forward_to_email: forwardToEmail } =
this.inbox;
// there might be a situation where the current conversation will include a message from a third person,
// and the current conversation contact is in CC.
// This is an edge-case, reported here: CW-1511 [ONLY FOR INTERNAL REFERENCE]
// So we remove the current conversation contact's email from the CC list if present
if (cc.includes(conversationContact)) {
cc = cc.filter(email => email !== conversationContact);
}
// If the last incoming message sender is different from the conversation contact, add them to the "to"
// and add the conversation contact to the CC
if (!emailAttributes.from.includes(conversationContact)) {
to.push(...emailAttributes.from);
cc.push(conversationContact);
}
// Remove the conversation contact's email from the BCC list if present
let bcc = (emailAttributes.bcc || []).filter(
email => email !== conversationContact
const { cc, bcc, to } = getRecipients(
this.lastEmail,
conversationContact,
inboxEmail,
forwardToEmail
);
// Ensure only unique email addresses are in the CC list
bcc = [...new Set(bcc)];
cc = [...new Set(cc)];
to = [...new Set(to)];
this.toEmails = to.join(', ');
this.ccEmails = cc.join(', ');
this.bccEmails = bcc.join(', ');
this.toEmails = to.join(', ');
},
fetchAndSetReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
@@ -40,10 +40,12 @@ export default {
},
emits: ['update:modelValue', 'input', 'blur'],
mounted() {
// eslint-disable-next-line
console.warn(
'[DEPRECATED] <WootInput> has be deprecated and will be removed soon. Please use v3/components/Form/Input.vue instead'
);
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.warn(
'[DEPRECATED] <WootInput> has be deprecated and will be removed soon. Please use v3/components/Form/Input.vue instead'
);
}
},
methods: {
onChange(e) {
@@ -152,7 +152,10 @@ export class DashboardAudioNotificationHelper {
const shouldPlayAudio = [];
if (audioAlertType.includes(EVENT_TYPES.ASSIGNED)) {
if (
audioAlertType.includes(EVENT_TYPES.ASSIGNED) ||
audioAlertType.includes('mine')
) {
shouldPlayAudio.push(assignedToMe);
}
if (audioAlertType.includes(EVENT_TYPES.UNASSIGNED)) {
@@ -27,18 +27,12 @@ const getters = {
const selectedChat = _getters.getSelectedChat;
const { messages = [] } = selectedChat;
const lastEmail = [...messages].reverse().find(message => {
const {
content_attributes: contentAttributes = {},
message_type: messageType,
} = message;
const { email = {} } = contentAttributes;
const isIncomingOrOutgoing =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.INCOMING;
if (email.from && isIncomingOrOutgoing) {
return true;
}
return false;
const { message_type: messageType } = message;
if (message.private) return false;
return [MESSAGE_TYPE.OUTGOING, MESSAGE_TYPE.INCOMING].includes(
messageType
);
});
return lastEmail;
@@ -40,10 +40,12 @@ export default {
},
},
created() {
// eslint-disable-next-line
console.warn(
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
);
if (import.meta.env.DEV) {
// eslint-disable-next-line
console.warn(
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
);
}
},
};
</script>
+36 -1
View File
@@ -1,9 +1,44 @@
class ActionCableBroadcastJob < ApplicationJob
queue_as :critical
include Events::Types
CONVERSATION_UPDATE_EVENTS = [
CONVERSATION_READ,
CONVERSATION_UPDATED,
TEAM_CHANGED,
ASSIGNEE_CHANGED,
CONVERSATION_STATUS_CHANGED
].freeze
def perform(members, event_name, data)
return if members.blank?
broadcast_data = prepare_broadcast_data(event_name, data)
broadcast_to_members(members, event_name, broadcast_data)
end
private
# Ensures that only the latest available data is sent to prevent UI issues
# caused by out-of-order events during high-traffic periods. This prevents
# the conversation job from processing outdated data.
def prepare_broadcast_data(event_name, data)
return data unless CONVERSATION_UPDATE_EVENTS.include?(event_name)
account = Account.find(data[:account_id])
conversation = account.conversations.find_by!(display_id: data[:id])
conversation.push_event_data.merge(account_id: data[:account_id])
end
def broadcast_to_members(members, event_name, broadcast_data)
members.each do |member|
ActionCable.server.broadcast(member, { event: event_name, data: data })
ActionCable.server.broadcast(
member,
{
event: event_name,
data: broadcast_data
}
)
end
end
end
+3 -1
View File
@@ -9,6 +9,7 @@
# external_url :string
# fallback_title :string
# file_type :integer default("image")
# meta :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@@ -116,7 +117,8 @@ class Attachment < ApplicationRecord
def contact_metadata
{
fallback_title: fallback_title
fallback_title: fallback_title,
meta: meta || {}
}
end
@@ -43,7 +43,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
last_activity_at: last_activity_at.to_i,
timestamp: last_activity_at.to_i,
created_at: created_at.to_i,
updated_at: updated_at.to_i
updated_at: updated_at.to_f
}
end
end
@@ -143,7 +143,11 @@ class Telegram::IncomingMessageService
@message.attachments.new(
account_id: @message.account_id,
file_type: :contact,
fallback_title: contact_card['phone_number'].to_s
fallback_title: contact_card['phone_number'].to_s,
meta: {
first_name: contact_card['first_name'],
last_name: contact_card['last_name']
}
)
end
@@ -44,7 +44,7 @@ json.muted conversation.muted?
json.snoozed_until conversation.snoozed_until
json.status conversation.status
json.created_at conversation.created_at.to_i
json.updated_at conversation.updated_at.to_i
json.updated_at conversation.updated_at.to_f
json.timestamp conversation.last_activity_at.to_i
json.first_reply_created_at conversation.first_reply_created_at.to_i
json.unread_count conversation.unread_incoming_messages.count
@@ -0,0 +1,5 @@
class AddMetaToAttachment < ActiveRecord::Migration[7.0]
def change
add_column :attachments, :meta, :jsonb, default: {}
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_01_16_061033) do
ActiveRecord::Schema[7.0].define(version: 2025_02_07_040150) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -174,6 +174,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_01_16_061033) do
t.datetime "updated_at", precision: nil, null: false
t.string "fallback_title"
t.string "extension"
t.jsonb "meta", default: {}
t.index ["account_id"], name: "index_attachments_on_account_id"
t.index ["message_id"], name: "index_attachments_on_message_id"
end
+1 -1
View File
@@ -33,7 +33,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/utils": "^0.0.35",
"@chatwoot/utils": "^0.0.38",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
+19 -9
View File
@@ -23,8 +23,8 @@ importers:
specifier: 1.1.1-next
version: 1.1.1-next
'@chatwoot/utils':
specifier: ^0.0.35
version: 0.0.35
specifier: ^0.0.38
version: 0.0.38
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -376,6 +376,10 @@ packages:
resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.26.7':
resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==}
engines: {node: '>=6.9.0'}
'@babel/types@7.26.0':
resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==}
engines: {node: '>=6.9.0'}
@@ -393,8 +397,8 @@ packages:
'@chatwoot/prosemirror-schema@1.1.1-next':
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
'@chatwoot/utils@0.0.35':
resolution: {integrity: sha512-uSRbd3pFp+IcEhsRtK1XGcFGFJc+X/YIwQnQrVDXsvsX3Mm7HEANj+Yz6J2clfHotajniwJwH2u5/y48+JrTyA==}
'@chatwoot/utils@0.0.38':
resolution: {integrity: sha512-6CTvuueBQLZJcm++pI2ZBY8Pp7OP3WzPCYyXoCagl8ZLpOfpjyVkLx9fc81falOoaVa/r+7EZ85Cv7vkT0ZyQw==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -2372,8 +2376,8 @@ packages:
resolution: {integrity: sha512-m1WR0xGiC6j6jNFAyW4Nvh4WxAi4JF4w9jRJwSI8nBmNcyZXPcP9VUQG+6gHQXAmqaGEKDKhOqAtENDC941UkA==}
engines: {node: '>=0.11'}
date-fns@2.29.3:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
date-format-parse@0.2.7:
@@ -5092,6 +5096,10 @@ snapshots:
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.26.7':
dependencies:
regenerator-runtime: 0.14.1
'@babel/types@7.26.0':
dependencies:
'@babel/helper-string-parser': 7.25.9
@@ -5125,9 +5133,9 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
'@chatwoot/utils@0.0.35':
'@chatwoot/utils@0.0.38':
dependencies:
date-fns: 2.29.3
date-fns: 2.30.0
'@codemirror/commands@6.7.0':
dependencies:
@@ -7380,7 +7388,9 @@ snapshots:
date-fns@2.21.1: {}
date-fns@2.29.3: {}
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.26.7
date-format-parse@0.2.7: {}
+4 -4
View File
@@ -33,26 +33,26 @@ describe PortalHelper do
describe '#generate_portal_bg' do
context 'when theme is dark' do
it 'returns the correct background with dark grid image and color mix with black' do
expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #ff0000 20%, black)'
expected_bg = 'color-mix(in srgb, #ff0000 20%, black)'
expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg)
end
end
context 'when theme is not dark' do
it 'returns the correct background with light grid image and color mix with white' do
expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #ff0000 20%, white)'
expected_bg = 'color-mix(in srgb, #ff0000 20%, white)'
expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg)
end
end
context 'when provided with various colors' do
it 'adjusts the background appropriately for dark theme' do
expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #00ff00 20%, black)'
expected_bg = 'color-mix(in srgb, #00ff00 20%, black)'
expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg)
end
it 'adjusts the background appropriately for light theme' do
expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #0000ff 20%, white)'
expected_bg = 'color-mix(in srgb, #0000ff 20%, white)'
expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg)
end
end
+61
View File
@@ -67,4 +67,65 @@ RSpec.describe Attachment do
expect(message.attachments.first.push_event_data[:data_url]).not_to eq message.attachments.first.external_url
end
end
describe 'meta data handling' do
let(:message) { create(:message) }
context 'when attachment is a contact type' do
let(:contact_attachment) do
message.attachments.create!(
account_id: message.account_id,
file_type: :contact,
fallback_title: '+1234567890',
meta: {
first_name: 'John',
last_name: 'Doe'
}
)
end
it 'stores and retrieves meta data correctly' do
expect(contact_attachment.meta['first_name']).to eq('John')
expect(contact_attachment.meta['last_name']).to eq('Doe')
end
it 'includes meta data in push_event_data' do
event_data = contact_attachment.push_event_data
expect(event_data[:meta]).to eq({
'first_name' => 'John',
'last_name' => 'Doe'
})
end
it 'returns empty hash for meta if not set' do
attachment = message.attachments.create!(
account_id: message.account_id,
file_type: :contact,
fallback_title: '+1234567890'
)
expect(attachment.push_event_data[:meta]).to eq({})
end
end
context 'when meta is used with other file types' do
let(:image_attachment) do
attachment = message.attachments.new(
account_id: message.account_id,
file_type: :image,
meta: { description: 'Test image' }
)
attachment.file.attach(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
attachment.save!
attachment
end
it 'preserves meta data with file attachments' do
expect(image_attachment.meta['description']).to eq('Test image')
end
end
end
end
+1 -1
View File
@@ -538,7 +538,7 @@ RSpec.describe Conversation do
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
created_at: conversation.created_at.to_i,
updated_at: conversation.updated_at.to_i,
updated_at: conversation.updated_at.to_f,
waiting_since: conversation.waiting_since.to_i,
priority: nil,
unread_count: 0
@@ -31,7 +31,7 @@ RSpec.describe Conversations::EventDataPresenter do
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
created_at: conversation.created_at.to_i,
updated_at: conversation.updated_at.to_i,
updated_at: conversation.updated_at.to_f,
waiting_since: conversation.waiting_since.to_i,
priority: nil,
unread_count: 0