diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..a31d28c2d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "additionalDirectories": ["../baileys-api"] +} diff --git a/.env.example b/.env.example index 0158c1f0f..ca4faf88b 100644 --- a/.env.example +++ b/.env.example @@ -293,5 +293,7 @@ AZURE_APP_SECRET= BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025 BAILEYS_PROVIDER_DEFAULT_API_KEY= +# Enable WhatsApp group conversations for Baileys provider (default: false) +BAILEYS_WHATSAPP_GROUPS_ENABLED=false RESEND_API_KEY= diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2c0e6087e..994b52078 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -55,7 +55,8 @@ class ContactInboxWithContactBuilder email: contact_attributes[:email], identifier: contact_attributes[:identifier], additional_attributes: contact_attributes[:additional_attributes], - custom_attributes: contact_attributes[:custom_attributes] + custom_attributes: contact_attributes[:custom_attributes], + group_type: contact_attributes[:group_type] || :individual ) end diff --git a/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb b/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb new file mode 100644 index 000000000..79a5837c9 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_admin_controller.rb @@ -0,0 +1,56 @@ +class Api::V1::Accounts::Contacts::GroupAdminController < Api::V1::Accounts::Contacts::BaseController + VALID_PROPERTIES = %w[announce restrict join_approval_mode member_add_mode].freeze + + def leave + authorize @contact, :update? + channel.group_leave(@contact.identifier) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def update + authorize @contact, :update? + property = property_params[:property] + enabled = ActiveModel::Type::Boolean.new.cast(property_params[:enabled]) + return render json: { error: 'invalid_property' }, status: :unprocessable_entity unless property.in?(VALID_PROPERTIES) + + apply_property_change(property, enabled) + update_contact_attribute(property, enabled) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def apply_property_change(property, enabled) + case property + when 'announce', 'restrict' + channel.group_setting_update(@contact.identifier, property, enabled) + when 'join_approval_mode' + channel.group_join_approval_mode(@contact.identifier, enabled ? 'on' : 'off') + when 'member_add_mode' + channel.group_member_add_mode(@contact.identifier, enabled ? 'all_member_add' : 'admin_add') + end + end + + def property_params + params.permit(:property, :enabled) + end + + def channel + @channel ||= @contact.group_channel + end + + def resolve_group_conversations + Current.account.conversations + .where(contact_id: @contact.id, group_type: :group, status: %i[open pending]) + .find_each { |c| c.update!(status: :resolved) } + end + + def update_contact_attribute(key, value) + new_attrs = (@contact.additional_attributes || {}).merge(key => value) + @contact.update!(additional_attributes: new_attrs) + end +end diff --git a/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb b/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb new file mode 100644 index 000000000..9d9c18cca --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_invites_controller.rb @@ -0,0 +1,27 @@ +class Api::V1::Accounts::Contacts::GroupInvitesController < Api::V1::Accounts::Contacts::BaseController + def show + authorize @contact, :show? + code = channel.group_invite_code(@contact.identifier) + render json: invite_response(code) + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def revoke + authorize @contact, :update? + code = channel.revoke_group_invite(@contact.identifier) + render json: invite_response(code) + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def channel + @channel ||= @contact.group_channel + end + + def invite_response(code) + { invite_code: code, invite_url: "https://chat.whatsapp.com/#{code}" } + end +end diff --git a/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb b/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb new file mode 100644 index 000000000..db1caabe6 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_join_requests_controller.rb @@ -0,0 +1,37 @@ +class Api::V1::Accounts::Contacts::GroupJoinRequestsController < Api::V1::Accounts::Contacts::BaseController + def index + authorize @contact, :show? + requests = channel.group_join_requests(@contact.identifier) + render json: { payload: requests } + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def handle + authorize @contact, :update? + channel.handle_group_join_requests(@contact.identifier, handle_params[:participants], handle_params[:request_action]) + remove_handled_requests(handle_params[:participants]) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def handle_params + params.permit(:request_action, participants: []) + end + + def channel + @channel ||= @contact.group_channel + end + + def remove_handled_requests(participants) + return if participants.blank? + + current_requests = @contact.additional_attributes&.dig('pending_join_requests') || [] + updated_requests = current_requests.reject { |r| participants.include?(r['jid']) } + new_attrs = (@contact.additional_attributes || {}).merge('pending_join_requests' => updated_requests) + @contact.update!(additional_attributes: new_attrs) + end +end diff --git a/app/controllers/api/v1/accounts/contacts/group_members_controller.rb b/app/controllers/api/v1/accounts/contacts/group_members_controller.rb new file mode 100644 index 000000000..8ce6ef3d2 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_members_controller.rb @@ -0,0 +1,155 @@ +class Api::V1::Accounts::Contacts::GroupMembersController < Api::V1::Accounts::Contacts::BaseController + DEFAULT_PER_PAGE = 10 + + before_action :ensure_group_contact, only: %i[create update destroy] + + def index + authorize @contact, :show? + + base_query = GroupMember.active + .where(group_contact: @contact) + .includes(:contact) + + @total_count = base_query.count + @page = [(params[:page] || 1).to_i, 1].max + @per_page = (params[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100) + @inbox_phone_number = inbox_phone_number + @is_inbox_admin = inbox_admin? + + paginated = base_query.order(role: :desc, id: :asc) + .offset((@page - 1) * @per_page) + .limit(@per_page) + + @group_members = pin_own_member_on_first_page(paginated) + end + + def create + authorize @contact, :update? + participants = create_params[:participants] + return render json: { error: 'participants_required' }, status: :unprocessable_entity if participants.blank? + + channel.update_group_participants(@contact.identifier, format_participants(participants), 'add') + add_group_members(participants) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def update + authorize @contact, :update? + role = update_params[:role] + return render json: { error: 'invalid_role' }, status: :unprocessable_entity unless %w[admin member].include?(role) + + member = group_members.find(params[:member_id]) + action = role == 'admin' ? 'promote' : 'demote' + channel.update_group_participants(@contact.identifier, [jid_for_member(member)], action) + member.update!(role: role) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError + render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def destroy + authorize @contact, :update? + + member = group_members.find(params[:id]) + channel.update_group_participants(@contact.identifier, [jid_for_member(member)], 'remove') + member.update!(is_active: false) + head :ok + rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError + render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def ensure_group_contact + return if @contact.group_type_group? && @contact.identifier.present? + + render json: { error: 'Contact is not a valid group' }, status: :unprocessable_entity + end + + def group_members + GroupMember.where(group_contact: @contact) + end + + def create_params + params.permit(participants: []) + end + + def update_params + params.permit(:role) + end + + def channel + @channel ||= @contact.group_channel + end + + def inbox_phone_number + channel&.phone_number + end + + def inbox_admin? + return false if @inbox_phone_number.blank? + + find_own_member&.role == 'admin' + end + + def pin_own_member_on_first_page(paginated) + return paginated unless @page == 1 && @inbox_phone_number.present? + + ids = paginated.pluck(:id) + own = find_own_member + return paginated if own.blank? || ids.include?(own.id) + + # Prepend own member; drop the last one so total per-page stays consistent + [own] + paginated.where.not(id: own.id).limit(@per_page - 1).to_a + end + + def find_own_member + clean = @inbox_phone_number.delete('+') + GroupMember.active + .where(group_contact: @contact) + .joins(:contact) + .where('REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)', + clean, clean) + .includes(:contact) + .first + end + + def format_participants(phone_numbers) + Array(phone_numbers).map { |phone| "#{phone.to_s.delete('+')}@s.whatsapp.net" } + end + + def jid_for_member(member) + "#{member.contact.phone_number.to_s.delete('+')}@s.whatsapp.net" + end + + def add_group_members(phone_numbers) + inbox = @contact.contact_inboxes.first&.inbox + Array(phone_numbers).each do |phone| + normalized = normalize_phone(phone) + next if normalized.blank? + + contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: normalized.delete('+'), + inbox: inbox, + contact_attributes: { name: normalized, phone_number: normalized } + ).perform + next if contact_inbox.blank? + + member = GroupMember.find_or_initialize_by(group_contact: @contact, contact: contact_inbox.contact) + member.update!(role: :member, is_active: true) unless member.persisted? && member.is_active? + end + end + + def normalize_phone(phone) + cleaned = phone.to_s.strip + return nil if cleaned.blank? + + cleaned.start_with?('+') ? cleaned : "+#{cleaned}" + end +end diff --git a/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb b/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb new file mode 100644 index 000000000..889236ace --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/group_metadata_controller.rb @@ -0,0 +1,39 @@ +class Api::V1::Accounts::Contacts::GroupMetadataController < Api::V1::Accounts::Contacts::BaseController + def update + authorize @contact, :update? + update_subject if metadata_params[:subject].present? + update_description if metadata_params[:description].present? + update_picture if metadata_params[:avatar].present? + render json: { id: @contact.id, name: @contact.name, additional_attributes: @contact.additional_attributes } + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def metadata_params + params.permit(:subject, :description, :avatar) + end + + def update_subject + channel.update_group_subject(@contact.identifier, metadata_params[:subject]) + @contact.update!(name: metadata_params[:subject]) + end + + def update_description + channel.update_group_description(@contact.identifier, metadata_params[:description]) + attrs = @contact.additional_attributes.merge('description' => metadata_params[:description]) + @contact.update!(additional_attributes: attrs) + end + + def update_picture + avatar = metadata_params[:avatar] + image_base64 = Base64.strict_encode64(avatar.read) + channel.update_group_picture(@contact.identifier, image_base64) + @contact.avatar.attach(avatar) + end + + def channel + @channel ||= @contact.group_channel + end +end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 14d4f2c89..2670bc0ba 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -13,7 +13,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search, :filter] - before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] + before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes, :sync_group] before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] def index @@ -82,6 +82,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController @contact.save! end + def sync_group + authorize @contact, :sync_group? + raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if @contact.group_type_individual? + raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if @contact.identifier.blank? + + Contacts::SyncGroupJob.perform_later(@contact) + head :accepted + end + def create ActiveRecord::Base.transaction do @contact = Current.account.contacts.new(permitted_params.except(:avatar_url)) diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index bd2186b0a..83873149e 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -166,7 +166,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # rubocop:enable Rails/SkipsModelValidations end + def unseen_activity? + @conversation.last_activity_at.present? && + (@conversation.agent_last_seen_at.blank? || @conversation.last_activity_at > @conversation.agent_last_seen_at) + end + def should_update_last_seen? + # Always update when there's unseen activity (e.g. soft-disabled group conversations that don't create messages) + return true if unseen_activity? + # Update if at least one relevant timestamp is older than 1 hour or not set # This prevents redundant DB writes when agents repeatedly view the same conversation agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago diff --git a/app/controllers/api/v1/accounts/groups_controller.rb b/app/controllers/api/v1/accounts/groups_controller.rb new file mode 100644 index 000000000..edad93c31 --- /dev/null +++ b/app/controllers/api/v1/accounts/groups_controller.rb @@ -0,0 +1,26 @@ +class Api::V1::Accounts::GroupsController < Api::V1::Accounts::BaseController + def create + inbox = Current.account.inboxes.find_by(id: group_params[:inbox_id]) + return render json: { error: 'Access Denied' }, status: :forbidden unless inbox_accessible?(inbox) + + result = Groups::CreateService.new( + inbox: inbox, + subject: group_params[:subject], + participants: Array(group_params[:participants]) + ).perform + + render json: result + rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def group_params + params.permit(:inbox_id, :subject, participants: []) + end + + def inbox_accessible?(inbox) + inbox.present? && Current.user.assigned_inboxes.exists?(id: inbox.id) && inbox.channel.try(:allow_group_creation?) + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d57ad0e53..5b18d3030 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -78,6 +78,7 @@ class DashboardController < ActionController::Base WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, + BAILEYS_WHATSAPP_GROUPS_ENABLED: Whatsapp::Providers::WhatsappBaileysService.groups_enabled?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), GIT_SHA: GIT_HASH, ALLOWED_LOGIN_METHODS: allowed_login_methods diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index d43ed31e7..cd4f91206 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -64,6 +64,7 @@ class ConversationFinder find_all_conversations filter_by_status unless params[:q] + filter_by_group_type filter_by_team filter_by_labels filter_by_query @@ -118,6 +119,12 @@ class ConversationFinder @conversations end + def filter_by_group_type + return unless params[:group_type].present? && params[:group_type] != 'all' + + @conversations = @conversations.where(group_type: params[:group_type]) + end + def filter_by_conversation_type case @params[:conversation_type] when 'mention' diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index fe03dae28..2bf492915 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -100,6 +100,10 @@ module Filters::FilterHelper values.map { |x| Conversation.priorities[x.to_sym] } end + def conversation_group_type_values(values) + values.map { |x| Conversation.group_types[x.to_sym] } + end + def message_type_values(values) values.map { |x| Message.message_types[x.to_sym] } end diff --git a/app/javascript/dashboard/api/groupMembers.js b/app/javascript/dashboard/api/groupMembers.js new file mode 100644 index 000000000..224e95eb1 --- /dev/null +++ b/app/javascript/dashboard/api/groupMembers.js @@ -0,0 +1,71 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class GroupMembersAPI extends ApiClient { + constructor() { + super('contacts', { accountScoped: true }); + } + + getGroupMembers(contactId, page = 1) { + return axios.get(`${this.url}/${contactId}/group_members`, { + params: { page }, + }); + } + + syncGroup(contactId) { + return axios.post(`${this.url}/${contactId}/sync_group`); + } + + createGroup(params) { + return axios.post(`${this.baseUrl()}/groups`, params); + } + + updateGroupMetadata(contactId, params) { + return axios.patch(`${this.url}/${contactId}/group_metadata`, params); + } + + addMembers(contactId, participants) { + return axios.post(`${this.url}/${contactId}/group_members`, { + participants, + }); + } + + removeMembers(contactId, memberId) { + return axios.delete(`${this.url}/${contactId}/group_members/${memberId}`); + } + + updateMemberRole(contactId, memberId, role) { + return axios.patch(`${this.url}/${contactId}/group_members/${memberId}`, { + role, + }); + } + + getInviteLink(contactId) { + return axios.get(`${this.url}/${contactId}/group_invite`); + } + + revokeInviteLink(contactId) { + return axios.post(`${this.url}/${contactId}/group_invite/revoke`); + } + + getPendingRequests(contactId) { + return axios.get(`${this.url}/${contactId}/group_join_requests`); + } + + handleJoinRequest(contactId, params) { + return axios.post( + `${this.url}/${contactId}/group_join_requests/handle`, + params + ); + } + + leaveGroup(contactId) { + return axios.post(`${this.url}/${contactId}/group_admin/leave`); + } + + updateGroupProperty(contactId, params) { + return axios.patch(`${this.url}/${contactId}/group_admin`, params); + } +} + +export default new GroupMembersAPI(); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index f94fca452..ea2802cae 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -16,6 +16,7 @@ class ConversationApi extends ApiClient { conversationType, sortBy, updatedWithin, + groupType, }) { return axios.get(this.url, { params: { @@ -28,6 +29,7 @@ class ConversationApi extends ApiClient { conversation_type: conversationType, sort_by: sortBy, updated_within: updatedWithin, + group_type: groupType, }, }); } diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 9aa365f9b..b61c1b80f 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -1,7 +1,8 @@ + + diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js index 274eecb49..c0317975e 100644 --- a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js +++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js @@ -14,6 +14,7 @@ export const CONVERSATION_ATTRIBUTES = { REFERER: 'referer', CREATED_AT: 'created_at', LAST_ACTIVITY_AT: 'last_activity_at', + GROUP_TYPE: 'group_type', }; export const CONTACT_ATTRIBUTES = { diff --git a/app/javascript/dashboard/components-next/filter/provider.js b/app/javascript/dashboard/components-next/filter/provider.js index fc418b132..82c7eafa7 100644 --- a/app/javascript/dashboard/components-next/filter/provider.js +++ b/app/javascript/dashboard/components-next/filter/provider.js @@ -247,6 +247,20 @@ export function useConversationFilterContext() { filterOperators: dateOperators.value, attributeModel: 'standard', }, + { + attributeKey: CONVERSATION_ATTRIBUTES.GROUP_TYPE, + value: CONVERSATION_ATTRIBUTES.GROUP_TYPE, + attributeName: t('FILTER.ATTRIBUTES.GROUP_TYPE'), + label: t('FILTER.ATTRIBUTES.GROUP_TYPE'), + inputType: 'multiSelect', + options: ['individual', 'group'].map(id => ({ + id, + name: t(`GROUP.FILTER.${id.toUpperCase()}`), + })), + dataType: 'text', + filterOperators: equalityOperators.value, + attributeModel: 'standard', + }, ...customFilterTypes.value, ]); diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index cf02d5319..fe0346451 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -6,7 +6,7 @@ import { useTrack } from 'dashboard/composables'; import { useMapGetter } from 'dashboard/composables/store'; import { emitter } from 'shared/helpers/mitt'; import { useI18n } from 'vue-i18n'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import { LocalStorage } from 'shared/helpers/localStorage'; import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; @@ -102,7 +102,7 @@ import { useBranding } from 'shared/composables/useBranding'; // eslint-disable-next-line vue/define-macros-order const props = defineProps({ - id: { type: Number, required: true }, + id: { type: [Number, String], required: true }, messageType: { type: Number, required: true, @@ -127,11 +127,13 @@ const props = defineProps({ createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties groupWithNext: { type: Boolean, default: false }, + groupWithPrevious: { type: Boolean, default: false }, inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties inboxSupportsReplyTo: { type: Object, default: () => ({}) }, inboxSupportsEdit: { type: Boolean, default: false }, inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties isEmailInbox: { type: Boolean, default: false }, + isGroupConversation: { type: Boolean, default: false }, private: { type: Boolean, default: false }, sender: { type: Object, default: null }, senderId: { type: Number, default: null }, @@ -148,6 +150,7 @@ const { t } = useI18n(); const route = useRoute(); const inboxGetter = useMapGetter('inboxes/getInbox'); const inbox = computed(() => inboxGetter.value(props.inboxId) || {}); +const router = useRouter(); const { replaceInstallationName } = useBranding(); /** @@ -245,7 +248,21 @@ const flexOrientationClass = computed(() => { return map[orientation.value]; }); +const isGroupIncoming = computed(() => { + return ( + props.isGroupConversation && props.messageType === MESSAGE_TYPES.INCOMING + ); +}); + +const showGroupSenderAvatar = computed(() => { + return isGroupIncoming.value && !props.groupWithPrevious; +}); + const gridClass = computed(() => { + if (orientation.value === ORIENTATION.LEFT && isGroupIncoming.value) { + return 'grid grid-cols-[24px_1fr]'; + } + const map = { [ORIENTATION.LEFT]: 'grid grid-cols-1fr', [ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]', @@ -255,6 +272,13 @@ const gridClass = computed(() => { }); const gridTemplate = computed(() => { + if (orientation.value === ORIENTATION.LEFT && isGroupIncoming.value) { + return ` + "avatar bubble" + "spacer meta" + `; + } + const map = { [ORIENTATION.LEFT]: ` "bubble" @@ -502,6 +526,47 @@ const avatarTooltip = computed(() => { return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`; }); +// Colors for group sender names, matching AVATAR_COLORS from Avatar component +const SENDER_NAME_COLORS = { + light: ['#C2298A', '#99543A', '#60646C', '#008573', '#4747C2', '#3A5BC7'], + dark: ['#FF8DCC', '#FFA366', '#ADB1B8', '#0BD8B6', '#A19EFF', '#9EB1FF'], +}; + +const showGroupSenderName = computed(() => { + return ( + props.isGroupConversation && + props.messageType === MESSAGE_TYPES.INCOMING && + !props.groupWithPrevious && + props.sender?.name + ); +}); + +const senderNameStyle = computed(() => { + if (!showGroupSenderName.value) return {}; + const name = props.sender?.name || ''; + const index = name.length % SENDER_NAME_COLORS.light.length; + return { + color: SENDER_NAME_COLORS.light[index], + '--dark-sender-color': SENDER_NAME_COLORS.dark[index], + }; +}); + +const navigateToGroupSender = event => { + if ( + !isGroupIncoming.value || + !props.sender?.id || + props.sender.type?.toLowerCase() !== 'contact' + ) + return; + const accountId = route.params.accountId; + const url = `/app/accounts/${accountId}/contacts/${props.sender.id}`; + if (event?.ctrlKey || event?.metaKey) { + window.open(url, '_blank'); + } else { + router.push(url); + } +}; + const setupHighlightTimer = () => { if (Number(route.query.messageId) !== Number(props.id)) { return; @@ -558,6 +623,18 @@ provideMessageContext({ gridTemplateAreas: gridTemplate, }" > +
+ +
-
- +
+ + {{ sender?.name }} + +
+ +
{ const currentChat = useMapGetter('getSelectedChat'); +const isGroupConversation = computed( + () => currentChat.value?.group_type === 'group' +); + // Cache for fetched reply messages to avoid duplicate API calls const fetchedReplyMessages = reactive(new Map()); @@ -180,6 +184,10 @@ const getInReplyToMessage = parentMessage => { :is-email-inbox="isAnEmailChannel" :in-reply-to="getInReplyToMessage(message)" :group-with-next="shouldGroupWithNext(index, allMessages)" + :group-with-previous=" + index > 0 && shouldGroupWithNext(index - 1, allMessages) + " + :is-group-conversation="isGroupConversation" :inbox-supports-reply-to="inboxSupportsReplyTo" :inbox-supports-edit="inboxSupportsEdit" :current-user-id="currentUserId" diff --git a/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue b/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue index afb29f6ed..095c12422 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Text/FormattedContent.vue @@ -139,7 +139,10 @@ const iconColorClass = computed(() => {