Compare commits

..
Author SHA1 Message Date
Muhsin 6b0eeb8d09 fix(twilio): fall back to phone lookup when MessagingServiceSid is unknown
Twilio includes `MessagingServiceSid` on inbound webhooks whenever the
number is part of a Messaging Service pool on Twilio's side (required
for US A2P 10DLC), even for Chatwoot channels registered by phone number
alone. The signature-verify concern was rejecting those webhooks with
403 because lookup-by-MSS returned nil and did not fall through.

Only reject outright when the MSS does resolve to a channel but the
AccountSid mismatches — the cross-tenant guard from the earlier review
is preserved.
2026-04-21 18:29:31 +04:00
6a9c44476e feat(super-admin): Add push diagnostics tool (#14105)
We're getting many customer reports saying "I'm not getting
notifications." We can't always identify the root cause since there are
multiple points of failure. Added a **Push Diagnostics** tool in Super
Admin to help us investigate mobile/web push issues.

Here's how it works:

- Look up a user by email/ID → see all their registered subscriptions
with device info (iOS/Android, brand, model), token freshness, and
last-updated time
- Send a customizable test push and read the raw FCM/web-push/relay
response to see if the customer is receiving push notifications—if not,
it will show proper errors.
- Delete broken subscriptions so the mobile app re-registers on next
launch
<img width="3816" height="1974" alt="CleanShot 2026-04-20 at 12 56
56@2x"
src="https://github.com/user-attachments/assets/08ecab6f-7ec3-44b3-a114-5e6eb8cf0879"
/>

Fixes https://linear.app/chatwoot/issue/CW-6892/push-diagnostics-tool

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:55:12 +04:00
6928f14a76 fix: Validate Twilio webhook signatures (X-Twilio-Signature) (#13638)
Fixes #13619

## Summary

- Add `TwilioSignatureVerifyConcern` that validates the
`X-Twilio-Signature` header using `Twilio::Security::RequestValidator`
(already bundled via `twilio-ruby` gem)
- Include the concern in `Twilio::CallbackController` and
`Twilio::DeliveryStatusController` — both endpoints were previously
accepting requests from any source with no authentication
- Channels using API key authentication (`api_key_sid` present) skip
validation with a warning log, since Twilio signs with the account auth
token which isn't stored for those channels

## How it works

1. `before_action` looks up the `Channel::TwilioSms` from request params
(`MessagingServiceSid` or `AccountSid` + phone number)
2. Validates the HMAC-SHA1 signature using the channel's auth token
3. Returns `403 Forbidden` if signature is invalid, missing, or channel
not found
4. Handles reverse proxy URL reconstruction via `X-Forwarded-Proto`
header

Follows the same pattern used by `Webhooks::ShopifyController` and
`Webhooks::TiktokController`.

## Test plan

- [x] Valid signature → 204 No Content, job enqueued
- [x] Invalid signature → 403 Forbidden, job not enqueued
- [x] Missing signature header → 403 Forbidden
- [x] Channel not found → 403 Forbidden
- [x] API key channel → skips validation, job enqueued (with warning
log)
- [x] MessagingServiceSid lookup → validates and enqueues
- [x] All existing Twilio service/job specs pass (99 examples, 0
failures)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-21 13:44:25 +04:00
Sivin VargheseandGitHub d2625d8544 fix: TypeError cannot read properties of null (reading 'name') (#14112) 2026-04-21 14:48:03 +05:30
Tanmay Deep SharmaandGitHub 7d42edd17f fix: sanitize parentheses from email From header to prevent SMTP 553 errors (#14075)
## Description

When an inbox name or business name contains parentheses — e.g. `Giro
Crédito - Soporte (Email` — the resulting From header becomes
unparseable by SMTP servers. The `(` is interpreted as an RFC 5322
comment start, swallowing the actual email address and causing a `553
Invalid email address` rejection.

Closes [CW-6323](https://linear.app/chatwoot/issue/CW-6323)


## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How to reproduce?

1. Set an inbox's name or business name to include a parenthesis, e.g.
`Support (Email`
2. Send an outgoing email reply from that inbox
3. Observe `Net::SMTPFatalError: 553 ... Invalid email address`

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-04-21 11:30:20 +05:30
Sivin VargheseandGitHub 437dd9d38c fix: prevent Ctrl+Enter adding extra line break on send (Windows) (#14077)
# Pull Request Template

## Description

This PR includes,
On Windows, pressing **Ctrl+Enter** in the reply editor was inserting an
unintended line break before sending. This led to two issues:

* **Unexpected blank lines**
After adding a line break with Shift+Enter and removing it with
Backspace, the editor looked correct. However, sending with Ctrl+Enter
reintroduced a hidden break, resulting in an extra blank line in the
final message.

* **Selected text being replaced**
When text was selected and Ctrl+Enter was pressed, the selection was
replaced with a line break instead of being sent.

Fixes
https://linear.app/chatwoot/issue/CW-6840/newline-bug-in-the-editor


### **Cause**

Two keyboard handlers responded to **Ctrl+Enter** on Windows:

* ProseMirror (`Mod-Enter`) inserted a hard break
* ReplyBox (`$mod+Enter`) triggered send

The existing guard only checked `metaKey` (Cmd), so it never worked on
Windows. As a result, a line break was inserted just before sending.

### **Solution**

Make the modifier check platform-aware so the editor correctly
intercepts the send shortcut:

* Added `detectOS`, `isMac`, and `OS` constants
* Introduced `hasPressedMod` (uses `metaKey` on macOS, `ctrlKey`
elsewhere)

This ensures Ctrl+Enter sends the message without modifying content,
while keeping existing behavior unchanged.


**NB:** macOS behavior with Cmd+Enter remains unchanged



## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Case 1: line break**

1. Type `hello`
2. Press Shift+Enter, then Backspace
3. Press Ctrl+Enter
   → Message contains an unexpected blank new line

**Case 2: Selection replaced**

1. Type two lines using Shift+Enter
2. Select text on the second line
3. Press Ctrl+Enter
   → Selected text is replaced and not sent

### Screencast

**Before**


https://github.com/user-attachments/assets/d6d285a9-260b-4711-8bbd-d0c8519e8d20

**After**



https://github.com/user-attachments/assets/c0ace1f7-5d22-44a2-8e08-22190ee21e61



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-04-20 18:16:41 +05:30
50720f2395 feat: Gate conversation continuity toggle behind inbound_emails feature flag (#13838)
The "conversation continuity via email" toggle was visible to all
accounts regardless of whether they had `inbound_emails` enabled.
Without inbound email infrastructure, replies to those follow-up emails
land in the agent's personal inbox instead of routing back into
Chatwoot. The feature appears to work but silently breaks the reply
path.

The toggle is now gated on the `inbound_emails` feature flag. On
self-hosted without the feature, the toggle is hidden entirely. On
cloud, it remains visible but disabled with upgrade messaging.

On the backend, `inbound_emails` is added to the manually managed
features list in `InternalAttributesService` so that Stripe webhook plan
syncs don't override it when support enables it for an account.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-04-20 17:47:24 +05:30
Sivin VargheseandGitHub 1de90ec136 fix: clean up conversation list rendering (#14107) 2026-04-20 15:48:06 +05:30
Sivin VargheseandGitHub 3a4738729d feat(v5): New expanded layout for chatlist (#13652) 2026-04-20 14:02:10 +05:30
55 changed files with 2688 additions and 616 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ class Email::BaseBuilder
end
def business_name
inbox.business_name || inbox.sanitized_name
inbox.sanitized_business_name
end
def account_support_email
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
def show; end
@@ -48,8 +48,7 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
otp_code: mfa_params[:otp_code]
).authenticate
return if authenticated
@@ -64,6 +63,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :backup_code, :password)
params.permit(:otp_code, :password)
end
end
@@ -0,0 +1,90 @@
module TwilioSignatureVerifyConcern
extend ActiveSupport::Concern
included do
before_action :verify_twilio_signature!
end
private
def verify_twilio_signature!
channel = find_twilio_channel
return log_and_reject_missing_channel if channel.blank?
return if channel.api_key_sid.present? && log_api_key_skip(channel)
head :forbidden unless valid_signature?(channel)
end
def log_and_reject_missing_channel
Rails.logger.warn(
'[TWILIO] Channel not found for webhook ' \
"account_sid=#{params[:AccountSid]} messaging_service_sid=#{params[:MessagingServiceSid]} " \
"to=#{params[:To]} from=#{params[:From]}"
)
head :forbidden
end
def log_api_key_skip(channel)
Rails.logger.warn(
'[TWILIO] Signature validation skipped: channel uses API key authentication. ' \
"account_sid=#{params[:AccountSid]} channel_id=#{channel.id}"
)
end
def valid_signature?(channel)
signature = request.headers['X-Twilio-Signature']
if signature.blank?
Rails.logger.warn("[TWILIO] Missing X-Twilio-Signature header account_sid=#{params[:AccountSid]}")
return false
end
validator = Twilio::Security::RequestValidator.new(channel.auth_token)
request_url = reconstruct_url
return true if validator.validate(request_url, request.request_parameters, signature)
Rails.logger.warn(
'[TWILIO] Signature validation failed ' \
"account_sid=#{params[:AccountSid]} channel_id=#{channel.id} url=#{request_url} ip=#{request.remote_ip}"
)
false
end
def find_twilio_channel
if params[:MessagingServiceSid].present?
channel = ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
if channel.present?
return channel if params[:AccountSid].blank? || channel.account_sid == params[:AccountSid]
return nil
end
end
return if params[:AccountSid].blank?
find_channel_by_phone_number
end
def find_channel_by_phone_number
channel_lookup_phone_numbers.each do |phone|
channel = ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: phone)
return channel if channel
end
nil
end
def channel_lookup_phone_numbers
[params[:To], params[:From]].compact_blank
end
def reconstruct_url
url = request.original_url
url = url.sub('http://', 'https://') if url.start_with?('http://') && https_request?
url
end
def https_request?
return true if request.ssl?
forwarded_proto = request.headers['X-Forwarded-Proto'].to_s.split(',').map(&:strip).find(&:present?)
forwarded_proto&.casecmp?('https')
end
end
@@ -0,0 +1,68 @@
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
def show
@query = params[:user_query].to_s.strip
@user = resolve_user(@query)
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
@results = []
end
def create
@user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
end
run_test_and_render(ids)
end
def destroy_subscriptions
user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
end
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
end
private
def run_test_and_render(ids)
@query = @user.id.to_s
@subscriptions = @user.notification_subscriptions.order(:id)
@results = Notification::PushTestService.new(
user: @user, subscription_ids: ids,
title: params[:push_title], body: params[:push_body]
).perform
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
render :show
end
def log_super_admin_action(message)
Rails.logger.info(
"[SuperAdmin] push diagnostics #{message} " \
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
)
end
def resolve_user(query)
return if query.blank?
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
end
def parsed_subscription_ids
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
end
end
@@ -1,4 +1,6 @@
class Twilio::CallbackController < ApplicationController
include TwilioSignatureVerifyConcern
def create
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
@@ -1,4 +1,6 @@
class Twilio::DeliveryStatusController < ApplicationController
include TwilioSignatureVerifyConcern
def create
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
@@ -18,4 +20,8 @@ class Twilio::DeliveryStatusController < ApplicationController
:ErrorMessage
)
end
def channel_lookup_phone_numbers
[params[:From]].compact_blank
end
end
-3
View File
@@ -5,7 +5,6 @@ import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import LowBackupCodesBanner from './components/app/LowBackupCodesBanner.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
@@ -31,7 +30,6 @@ export default {
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
LowBackupCodesBanner,
},
setup() {
const router = useRouter();
@@ -142,7 +140,6 @@ export default {
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
<LowBackupCodesBanner v-if="hideOnOnboardingView" />
</template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
+2 -2
View File
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, { otpCode, backupCode } = {}) {
disable(password, otpCode) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode, backup_code: backupCode },
data: { password, otp_code: otpCode },
});
}
@@ -0,0 +1,58 @@
<script setup>
import { ref, computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
contact: { type: Object, required: true },
selected: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
hideThumbnail: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation']);
const hovered = ref(false);
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const selectedModel = computed({
get: () => props.selected,
set: value => {
emit('selectConversation', value);
},
});
</script>
<template>
<div
class="relative flex items-center flex-shrink-0"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="contact.name"
:src="contact.thumbnail"
:size="24"
:status="contact.availability_status"
hide-offline-status
>
<template v-if="enableSelection" #overlay>
<div
v-if="hovered || selected"
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
@click.stop
>
<Checkbox v-model="selectedModel" />
</div>
</template>
</Avatar>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import UnreadBadge from './UnreadBadge.vue';
defineProps({
lastMessage: { type: Object, default: null },
voiceCallStatus: { type: String, default: '' },
voiceCallDirection: { type: String, default: '' },
unreadCount: { type: Number, default: 0 },
showExpandedPreview: { type: Boolean, default: false },
});
</script>
<template>
<div
class="grid grid-cols-[1fr_auto] gap-1.5"
:class="showExpandedPreview ? 'items-end' : 'items-center'"
>
<VoiceCallStatus
v-if="voiceCallStatus"
key="voice-status-row"
:status="voiceCallStatus"
:direction="voiceCallDirection"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<MessagePreview
v-else-if="lastMessage"
key="message-preview"
:message="lastMessage"
:multi-line="showExpandedPreview"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<span
v-else
key="no-messages"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
>
<Icon icon="i-lucide-info" class="size-3.5" />
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
</div>
</template>
@@ -0,0 +1,175 @@
<script setup>
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useThrottleFn } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
labels: {
type: Array,
default: () => [],
},
disableToggle: {
type: Boolean,
default: false,
},
});
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const slots = useSlots();
const { t } = useI18n();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.labels.includes(title)
);
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
// Show if there are labels OR if before slot exists
const showSection = computed(
() => activeLabels.value.length > 0 || !!slots.before
);
const computeVisibleLabelPosition = () => {
const container = labelContainer.value;
if (!container || activeLabels.value.length === 0) {
showExpandLabelButton.value = false;
return;
}
const labels = container.querySelectorAll('[data-label]');
if (labels.length === 0) {
showExpandLabelButton.value = false;
return;
}
// Early exit if all labels are visible
if (showAllLabels.value) return;
const beforeSlot = container.querySelector('[data-before-slot]');
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
let totalWidth = 0;
const labelsArray = Array.from(labels);
// Find last visible label index using some() - stops early on overflow
const overflowIndex = labelsArray.findIndex(label => {
totalWidth += label.offsetWidth + 6;
return totalWidth > availableWidth;
});
const visibleIndex =
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
labelPosition.value = visibleIndex;
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
};
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
const hiddenLabelsCount = computed(() => {
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
return activeLabels.value.length - labelPosition.value - 1;
});
// Check if all labels are hidden (none visible)
const allLabelsHidden = computed(() => {
return labelPosition.value === -1 && activeLabels.value.length > 0;
});
// Label text for button when disableToggle is true and all labels are hidden
const labelsCountText = computed(() => {
if (props.disableToggle && allLabelsHidden.value) {
return t('CONVERSATION.CARD.LABELS_COUNT', {
count: activeLabels.value.length,
});
}
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
return hiddenLabelsCount.value;
}
return '';
});
const hiddenLabelsTooltip = computed(() => {
if (!props.disableToggle) return '';
// When all labels are hidden, show all label titles
if (allLabelsHidden.value) {
return activeLabels.value.map(label => label.title).join(', ');
}
if (!showExpandLabelButton.value) return '';
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
return hiddenLabels.map(label => label.title).join(', ');
});
const tooltipText = computed(() => {
if (props.disableToggle && hiddenLabelsTooltip.value) {
return hiddenLabelsTooltip.value;
}
return showAllLabels.value
? t('CONVERSATION.CARD.HIDE_LABELS')
: t('CONVERSATION.CARD.SHOW_LABELS');
});
const onShowLabels = e => {
e.stopPropagation();
if (props.disableToggle) return;
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
};
</script>
<template>
<div
v-if="showSection"
ref="labelContainer"
v-bind="attrs"
v-resize="throttledCalculate"
data-labels-container
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<slot name="before" />
<Label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:label="label"
compact
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<Button
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
v-tooltip.top="{
content: tooltipText,
delay: { show: 500, hide: 0 },
}"
:label="labelsCountText"
xs
slate
:no-animation="disableToggle"
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
:class="{ 'cursor-default': disableToggle }"
@click="onShowLabels"
/>
</div>
<template v-else />
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
};
const iconName = computed(() => {
if (props.status && icons[props.status]) {
return icons[props.status];
}
return props.showEmpty ? 'i-woot-status-empty' : '';
});
</script>
<template>
<Icon
v-tooltip.top="{
content: status,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 flex-shrink-0"
/>
</template>
@@ -0,0 +1,186 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabelsV5.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits([
'selectConversation',
'deSelectConversation',
'click',
'contextmenu',
]);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
get: () => props.selected,
set: value => {
if (value) {
emit('selectConversation', value);
} else {
emit('deSelectConversation', value);
}
},
});
</script>
<template>
<div
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
'hover:bg-n-alpha-1': !isActiveChat && !selected,
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<!-- LEFT SECTION -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
<Checkbox v-model="selectedModel" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardPriorityIcon :priority="chat.priority" show-empty />
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
/>
<Icon
v-else
icon="i-woot-empty-assignee"
class="size-4 text-n-slate-7"
/>
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardStatusIcon :status="chat.status" show-empty />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div
v-if="!isInboxView && showInboxName"
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
/>
<div
v-tooltip.top="{
content: chat.id,
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-11 truncate">
{{ chat.id }}
</span>
</div>
<CardAvatar
:contact="currentContact"
:selected="false"
:enable-selection="false"
:hide-thumbnail="false"
/>
<h4
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
>
{{ currentContact.name }}
</h4>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="false"
/>
</div>
<!-- RIGHT SECTION -->
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
class="my-0 [&>div]:justify-end justify-end"
/>
</div>
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
<SLACardLabel ref="slaCardLabel" :chat="chat" />
</div>
<div class="flex-shrink-0 w-[4.375rem] text-end">
<TimeAgo
:conversation-id="chat.id"
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
class="font-440 !text-xs text-n-slate-11"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,156 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
multiLine: {
type: Boolean,
default: false,
},
});
const { getPlainText } = useMessageFormatter();
const attachmentIcons = {
image: 'i-lucide-image',
audio: 'i-lucide-headphones',
video: 'i-lucide-video',
file: 'i-lucide-file',
location: 'i-lucide-map-pin',
fallback: 'i-lucide-link-2',
};
const messageByAgent = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const isMessageAnActivity = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
});
const isMessagePrivate = computed(() => {
const { private: isPrivate } = props.message;
return isPrivate;
});
const parsedLastMessage = computed(() => {
const { content_attributes: contentAttributes } = props.message;
const { email: { subject } = {} } = contentAttributes || {};
return getPlainText(subject || props.message.content);
});
const lastMessageFileType = computed(() => {
const [{ file_type: fileType } = {}] = props.message.attachments;
return fileType;
});
const attachmentIcon = computed(() => {
return attachmentIcons[lastMessageFileType.value];
});
const attachmentMessageContent = computed(() => {
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
});
const isMessageSticker = computed(() => {
return props.message && props.message.content_type === 'sticker';
});
</script>
<template>
<div
class="min-w-0 text-sm"
:class="
multiLine
? 'flex items-start gap-1'
: 'grid grid-cols-[auto_1fr] items-center gap-1'
"
>
<template v-if="showMessageType && !multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="size-3.5"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="size-3.5"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="size-3.5"
/>
</template>
<span
class="min-w-0 text-body-main"
:class="multiLine ? 'line-clamp-2' : 'truncate'"
>
<!-- Case for previous and conversation conversation card -->
<template v-if="showMessageType && multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
</template>
<span
v-if="message.content && isMessageSticker"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
>
<Icon icon="i-lucide-image" class="size-3.5" />
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<template v-else-if="message.content">
{{ parsedLastMessage }}
</template>
<span
v-else-if="message.attachments"
class="inline-block align-middle truncate"
>
<Icon
v-if="attachmentIcon && showMessageType"
:icon="attachmentIcon"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<span class="inline-block align-middle">
{{ $t(attachmentMessageContent) }}
</span>
</span>
<template v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</template>
</span>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
count: { type: Number, required: true },
alignBottom: { type: Boolean, default: false },
});
const { t } = useI18n();
const displayCount = computed(() =>
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
);
</script>
<template>
<span
v-if="count > 0"
class="bg-n-teal-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
:class="{
'mb-0.5': alignBottom,
}"
>
{{ displayCount }}
</span>
<span v-else />
</template>
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
<span class="truncate text-body-main" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
inbox: {
type: Object,
default: () => {},
},
});
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-body-main text-n-slate-11">
{{ inbox.name }}
</span>
</div>
</template>
@@ -0,0 +1,82 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
defineOptions({
inheritAttrs: false,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.chat, updateSlaStatus);
defineExpose({
hasSlaThreshold,
});
</script>
<template>
<div
v-if="hasSlaThreshold"
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
</Label>
</div>
<template v-else />
</template>
@@ -220,7 +220,7 @@ useEventListener(document, 'paste', onPaste);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -1,15 +1,5 @@
<script setup>
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
// composables and components, useVirtualChatList, useChatlistFilters
import {
ref,
unref,
provide,
computed,
watch,
onMounted,
defineEmits,
} from 'vue';
import { ref, unref, provide, computed, watch, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import {
@@ -17,23 +7,19 @@ import {
useFunctionGetter,
} from 'dashboard/composables/store.js';
import { Virtualizer } from 'virtua/vue';
import ChatListHeader from './ChatListHeader.vue';
import ConversationList from './ConversationList.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useFilter } from 'shared/composables/useFilter';
import { useTrack } from 'dashboard/composables';
@@ -85,10 +71,6 @@ const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
provide('contextMenuElementTarget', virtualListRef);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -100,7 +82,6 @@ const chatsOnView = ref([]);
const foldersQuery = ref({});
const showAddFoldersModal = ref(false);
const showDeleteFoldersModal = ref(false);
const isContextMenuOpen = ref(false);
const appliedFilter = ref([]);
const advancedFilterTypes = ref(
advancedFilterOptions.map(filter => ({
@@ -130,7 +111,6 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
const getTeamFn = useMapGetter('teams/getTeam');
const getConversationById = useMapGetter('getConversationById');
useChatListKeyboardEvents(conversationListRef);
const {
selectedConversations,
selectedInboxes,
@@ -360,7 +340,7 @@ const conversationList = computed(() => {
});
const showEndOfListMessage = computed(() => {
return (
return !!(
conversationList.value.length &&
hasCurrentPageEndReached.value &&
!chatListLoading.value
@@ -605,14 +585,6 @@ function loadMoreConversations() {
}
}
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
// If the list doesn’t fill the viewport, loading can stall.
// IntersectionObserver triggers as soon as the sentinel is visible.
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) {
resetBulkActions();
@@ -806,10 +778,6 @@ function allSelectedConversationsStatus(status) {
});
}
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
}
function toggleSelectAll(check) {
selectAllConversations(check, conversationList);
}
@@ -857,7 +825,6 @@ provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
@@ -970,43 +937,18 @@ watch(conversationFilters, (newVal, oldVal) => {
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
:data="conversationList"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:data-index="index"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</Virtualizer>
<div v-if="chatListLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</div>
<ConversationList
:conversation-list="conversationList"
:is-loading="chatListLoading"
:show-end-of-list-message="showEndOfListMessage"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:is-on-expanded-layout="isOnExpandedLayout"
@load-more="loadMoreConversations"
/>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
@@ -1,74 +1,244 @@
<script>
<script setup>
import { computed, ref, watch, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
},
inject: [
'selectConversation',
'deSelectConversation',
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
type: Object,
required: true,
},
teamId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
},
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from './widgets/conversation/contextMenu/Index.vue';
const props = defineProps({
source: { type: Object, required: true },
teamId: { type: [String, Number], default: 0 },
label: { type: String, default: '' },
conversationType: { type: String, default: '' },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
showExpanded: { type: Boolean, default: false },
});
const router = useRouter();
const store = useStore();
const selectConversation = inject('selectConversation');
const deSelectConversation = inject('deSelectConversation');
const assignAgent = inject('assignAgent');
const assignTeam = inject('assignTeam');
const assignLabels = inject('assignLabels');
const removeLabels = inject('removeLabels');
const updateConversationStatus = inject('updateConversationStatus');
const toggleContextMenu = inject('toggleContextMenu');
const markAsUnread = inject('markAsUnread');
const markAsRead = inject('markAsRead');
const assignPriority = inject('assignPriority');
const isConversationSelected = inject('isConversationSelected');
const deleteConversation = inject('deleteConversation');
// --- Context menu state (shared by both layouts) ---
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
// Reset context menu state when the row is recycled to a different conversation.
watch(
() => props.source.id,
() => {
if (showContextMenu.value) {
toggleContextMenu(false);
}
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
}
);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.source.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() =>
senderId.value ? store.getters['contacts/getContact'](senderId.value) : {}
);
const isActiveChat = computed(() => currentChat.value.id === props.source.id);
const inbox = computed(() => {
const inboxId = props.source.inbox_id;
return inboxId ? store.getters['inboxes/getInbox'](inboxId) : {};
});
const showInboxName = computed(
() => !activeInbox.value && inboxesList.value.length > 1
);
const isInboxView = computed(() => !!activeInbox.value);
const showAssigneeForExpandedCard = computed(
() => props.showExpanded || props.showAssignee
);
const conversationPath = computed(() =>
frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.source.id,
label: props.label,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
)
);
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
if (isActiveChat.value) return;
router.push({ path });
};
const onExpandedSelect = checked => {
if (checked) {
selectConversation(props.source.id, inbox.value.id);
} else {
deSelectConversation(props.source.id, inbox.value.id);
}
};
const openContextMenu = e => {
e.preventDefault();
toggleContextMenu(true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
toggleContextMenu(false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
updateConversationStatus(props.source.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
assignAgent(agent, [props.source.id]);
closeContextMenu();
};
const onAssignLabel = label => {
assignLabels([label.title], [props.source.id]);
};
const onRemoveLabel = label => {
removeLabels([label.title], [props.source.id]);
};
const onAssignTeam = team => {
assignTeam(team, props.source.id);
closeContextMenu();
};
const onMarkAsUnread = () => {
markAsUnread(props.source.id);
closeContextMenu();
};
const onMarkAsRead = () => {
markAsRead(props.source.id);
closeContextMenu();
};
const onAssignPriority = priority => {
assignPriority(priority, props.source.id);
closeContextMenu();
};
const onDeleteConversation = () => {
deleteConversation(props.source.id);
closeContextMenu();
};
</script>
<template>
<ConversationCard
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
<!-- Expanded layout: wide screen + expanded setting -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="source"
:conversation-type="conversationType"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssigneeForExpandedCard"
:show-inbox-name="showInboxName"
:is-inbox-view="isInboxView"
@select-conversation="onExpandedSelect"
@de-select-conversation="onExpandedSelect"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<!-- Default (condensed) layout -->
<ConversationCard
v-else
:chat="source"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="isConversationSelected(source.id)"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
enable-context-menu
:show-inbox-name="showInboxName"
@click="onCardClick"
@contextmenu="openContextMenu"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
<!-- Shared context menu for both layouts -->
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="source.status"
:inbox-id="inbox.id"
:priority="source.priority"
:chat-id="source.id"
:has-unread-messages="source.unread_count > 0"
:conversation-labels="source.labels"
:conversation-url="conversationPath"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="onMarkAsUnread"
@mark-as-read="onMarkAsRead"
@assign-priority="onAssignPriority"
@delete-conversation="onDeleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</template>
@@ -0,0 +1,94 @@
<script setup>
import { ref, computed, provide } from 'vue';
import { Virtualizer } from 'virtua/vue';
import { useBreakpoints } from '@vueuse/core';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import ConversationItem from './ConversationItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
conversationList: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
showEndOfListMessage: { type: Boolean, default: false },
label: { type: String, default: '' },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
conversationType: { type: String, default: '' },
showAssignee: { type: Boolean, default: false },
isOnExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
const isContextMenuOpen = ref(false);
provide('contextMenuElementTarget', virtualListRef);
const breakpoints = useBreakpoints({
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
});
const isLgScreen = breakpoints.greaterOrEqual('lg');
const showExpandedCards = computed(
() => props.isOnExpandedLayout && isLgScreen.value
);
useChatListKeyboardEvents(conversationListRef);
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
const onContextMenuToggle = state => {
isContextMenuOpen.value = state;
};
const loadMoreConversations = () => {
emit('loadMore');
};
provide('toggleContextMenu', onContextMenuToggle);
defineExpose({ conversationListRef });
</script>
<template>
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item }"
:data="conversationList"
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssignee"
:show-expanded="showExpandedCards"
/>
</Virtualizer>
<div v-if="isLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p v-else-if="showEndOfListMessage" class="p-4 text-center text-n-slate-11">
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</div>
</template>
@@ -1,79 +0,0 @@
<script setup>
import { computed, onUnmounted, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { parseBoolean } from '@chatwoot/utils';
import { useMapGetter } from 'dashboard/composables/store';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import mfaAPI from 'dashboard/api/mfa';
import Banner from 'dashboard/components/ui/Banner.vue';
const LOW_BACKUP_CODES_THRESHOLD = 3;
const { t } = useI18n();
const router = useRouter();
const mfaEnabled = ref(false);
const remainingBackupCodes = ref(null);
const currentAccountId = useMapGetter('getCurrentAccountId');
const shouldShowBanner = computed(() => {
if (!mfaEnabled.value) return false;
if (remainingBackupCodes.value === null) return false;
return remainingBackupCodes.value <= LOW_BACKUP_CODES_THRESHOLD;
});
const bannerColorScheme = computed(() =>
remainingBackupCodes.value === 0 ? 'alert' : 'warning'
);
const bannerMessage = computed(() => {
if (remainingBackupCodes.value === 0) {
return t('MFA_SETTINGS.LOW_BACKUP_CODES.NONE_LEFT');
}
return t('MFA_SETTINGS.LOW_BACKUP_CODES.MESSAGE', remainingBackupCodes.value);
});
const fetchMfaStatus = async () => {
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) return;
try {
const { data } = await mfaAPI.get();
mfaEnabled.value = data.enabled;
remainingBackupCodes.value = data.remaining_backup_codes ?? null;
} catch {
// ignore; banner stays hidden
}
};
const goToMfaSettings = () => {
router.push({
name: 'profile_settings_mfa',
params: { accountId: currentAccountId.value },
});
};
onMounted(() => {
fetchMfaStatus();
emitter.on(BUS_EVENTS.MFA_STATE_CHANGED, fetchMfaStatus);
});
onUnmounted(() => {
emitter.off(BUS_EVENTS.MFA_STATE_CHANGED, fetchMfaStatus);
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
:color-scheme="bannerColorScheme"
:banner-message="bannerMessage"
:action-button-label="$t('MFA_SETTINGS.LOW_BACKUP_CODES.ACTION')"
action-button-icon="i-lucide-key"
has-action-button
@primary-action="goToMfaSettings"
/>
</template>
@@ -1,98 +1,41 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import UnreadBadge from 'dashboard/components-next/Conversation/ConversationCard/UnreadBadge.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'click',
'contextmenu',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const store = useStore();
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
// This prevents context menu/hover state from leaking to a different conversation
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
const resetState = () => {
if (showContextMenu.value) {
emit('contextMenuToggle', false);
}
hovered.value = false;
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
};
watch(() => props.chat.id, resetState);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
@@ -100,24 +43,10 @@ const voiceCallData = computed(() => ({
direction: props.chat.additional_attributes?.call_direction,
}));
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.showInboxName ||
(props.showAssignee && props.assignee.name) ||
props.chat.priority
);
});
@@ -136,41 +65,6 @@ const messagePreviewClass = computed(() => {
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
@@ -181,83 +75,37 @@ const onThumbnailLeave = () => {
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
emit('selectConversation', props.chat.id, props.inbox.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
emit('deSelectConversation', props.chat.id, props.inbox.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const selectedModel = computed({
get: () => props.selected,
set: value => onSelectConversation(value),
});
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
watch(
() => props.chat.id,
() => {
hovered.value = false;
}
);
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
:class="{
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'active animate-card-select bg-n-background !border-n-surface-1':
isActiveChat,
'selected bg-n-slate-2 !border-n-surface-1': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div
class="relative"
@@ -272,7 +120,6 @@ const deleteConversation = () => {
:status="currentContact.availability_status"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
>
<template #overlay="{ size }">
<label
@@ -281,20 +128,12 @@ const deleteConversation = () => {
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
<Checkbox v-model="selectedModel" />
</label>
</template>
</Avatar>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="px-0 py-3 flex-1 min-w-0 border-line">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
@@ -369,12 +208,11 @@ const deleteConversation = () => {
:conversation-id="chat.id"
/>
</span>
<span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
<UnreadBadge
v-if="hasUnread"
:count="unreadCount"
class="ltr:ml-auto rtl:mr-auto mt-1"
/>
</div>
<CardLabels
v-if="showLabelsSection"
@@ -386,32 +224,5 @@ const deleteConversation = () => {
</template>
</CardLabels>
</div>
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
</template>
@@ -45,6 +45,7 @@ export default {
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
SMALL_SCREEN_BREAKPOINT: 768,
LARGE_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
@@ -140,6 +140,7 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending"
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
}
}
@@ -71,7 +71,8 @@
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
"HIDE_LABELS": "Hide labels",
"LABELS_COUNT": "{count} labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -745,6 +745,7 @@
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "Inbox Settings",
@@ -49,21 +49,12 @@
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"LOW_BACKUP_CODES": {
"MESSAGE": "You have {n} backup code remaining. Generate new codes to avoid getting locked out if you lose access to your authenticator. | You have {n} backup codes remaining. Generate new codes to avoid getting locked out if you lose access to your authenticator.",
"NONE_LEFT": "You have no backup codes remaining. Generate new codes now to avoid getting locked out if you lose access to your authenticator.",
"ACTION": "Generate codes"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -1,49 +1,131 @@
<script>
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import {
isOnMentionsView,
isOnUnattendedView,
isOnFoldersView,
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
import { mapGetters } from 'vuex';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
ConversationCard,
Spinner,
},
props: {
contactId: {
type: [String, Number],
required: true,
},
conversationId: {
type: [String, Number],
required: true,
},
},
computed: {
conversations() {
return this.$store.getters['contactConversations/getContactConversation'](
this.contactId
);
},
previousConversations() {
return this.conversations.filter(
conversation => conversation.id !== Number(this.conversationId)
);
},
...mapGetters({
uiFlags: 'contactConversations/getUIFlags',
}),
},
watch: {
contactId(newContactId, prevContactId) {
if (newContactId && newContactId !== prevContactId) {
this.$store.dispatch('contactConversations/get', newContactId);
}
},
},
mounted() {
this.$store.dispatch('contactConversations/get', this.contactId);
},
const props = defineProps({
contactId: { type: [String, Number], required: true },
conversationId: { type: [String, Number], required: true },
});
const store = useStore();
const route = useRoute();
const router = useRouter();
const currentChat = useMapGetter('getSelectedChat');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const contactGetter = useMapGetter('contacts/getContact');
const inboxGetter = useMapGetter('inboxes/getInbox');
const activeInbox = useMapGetter('getSelectedInbox');
const inboxesList = useMapGetter('inboxes/getInboxes');
const showInboxName = computed(
() => !activeInbox.value && inboxesList.value.length > 1
);
const contactConversationGetter = useMapGetter(
'contactConversations/getContactConversation'
);
const conversations = computed(() =>
contactConversationGetter.value(props.contactId)
);
const previousConversations = computed(() =>
conversations.value.filter(c => c.id !== Number(props.conversationId))
);
const activeContextChat = ref(null);
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
const buildConversationUrl = conversationId => {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
return frontendURL(
conversationUrl({
accountId,
activeInbox: inboxId,
id: conversationId,
label,
teamId,
foldersId: isOnFoldersView({ route: { name } }) ? route.params.id : 0,
conversationType,
})
);
};
const conversationPath = computed(() => {
if (!activeContextChat.value) return '';
return buildConversationUrl(activeContextChat.value.id);
});
const onCardClick = (conversation, e) => {
const path = buildConversationUrl(conversation.id);
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
router.push({ path });
};
const openContextMenu = (conversation, e) => {
e.preventDefault();
activeContextChat.value = conversation;
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
activeContextChat.value = null;
};
watch(
() => props.contactId,
(newId, oldId) => {
if (newId && newId !== oldId) {
showContextMenu.value = false;
activeContextChat.value = null;
store.dispatch('contactConversations/get', newId);
}
}
);
onMounted(() => {
store.dispatch('contactConversations/get', props.contactId);
});
</script>
<template>
@@ -53,18 +135,43 @@ export default {
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
</span>
</div>
<div v-else class="contact-conversation--list">
<div
v-else
class="contact-conversation--list [&>.conversation:last-child]:!border-b-0 [&>.conversation:last-child:hover]:!border-b-0 [&>.conversation:last-child]:!rounded-b-lg"
>
<ConversationCard
v-for="conversation in previousConversations"
:key="conversation.id"
:chat="conversation"
:hide-inbox-name="false"
:current-contact="contactGetter(conversation.meta?.sender?.id) || {}"
:assignee="conversation.meta?.assignee || {}"
:inbox="inboxGetter(conversation.inbox_id) || {}"
:is-active-chat="currentChat.id === conversation.id"
:show-inbox-name="showInboxName"
hide-thumbnail
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
@click="onCardClick(conversation, $event)"
@contextmenu="openContextMenu(conversation, $event)"
/>
</div>
<ContextMenu
v-if="showContextMenu && activeContextChat"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="activeContextChat.status"
:inbox-id="activeContextChat.inbox_id"
:priority="activeContextChat.priority"
:chat-id="activeContextChat.id"
:has-unread-messages="activeContextChat.unread_count > 0"
:conversation-labels="activeContextChat.labels"
:conversation-url="conversationPath"
:allowed-options="['open-new-tab', 'copy-link']"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
<div v-else class="flex items-center justify-center py-5">
<Spinner />
@@ -125,7 +125,7 @@ export default {
set(priorityItem) {
const conversationId = this.currentChat.id;
const oldValue = this.currentChat?.priority;
const priority = priorityItem ? priorityItem.id : null;
const priority = priorityItem.id;
this.$store.dispatch('setCurrentChatPriority', {
priority,
@@ -203,7 +203,9 @@ export default {
this.assignedPriority &&
this.assignedPriority.id === selectedPriorityItem.id;
this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
this.assignedPriority = isSamePriority
? this.priorityOptions[0]
: selectedPriorityItem;
},
},
};
@@ -112,9 +112,33 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
uiFlags: 'inboxes/getUIFlags',
portals: 'portals/allPortals',
}),
isInboundEmailEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.INBOUND_EMAILS
);
},
showContinuityToggle() {
if (this.isInboundEmailEnabled) return true;
return this.isOnChatwootCloud;
},
isContinuityDisabled() {
return this.isOnChatwootCloud && !this.isInboundEmailEnabled;
},
continuityDescription() {
if (this.isContinuityDisabled) {
return this.$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT'
);
}
return this.$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
);
},
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
@@ -542,7 +566,8 @@ export default {
welcome_tagline: this.channelWelcomeTagline || '',
selectedFeatureFlags: this.selectedFeatureFlags,
reply_time: this.replyTime || 'in_a_few_minutes',
continuity_via_email: this.continuityViaEmail,
continuity_via_email:
this.isInboundEmailEnabled && this.continuityViaEmail,
},
};
if (this.avatarFile) {
@@ -1148,15 +1173,15 @@ export default {
/>
<SettingsToggleSection
v-if="isAWebWidgetInbox"
v-if="isAWebWidgetInbox && showContinuityToggle"
v-model="continuityViaEmail"
:header="
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL')
"
:description="
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
)
:description="continuityDescription"
:hide-toggle="isContinuityDisabled"
:class="
isContinuityDisabled ? 'cursor-not-allowed opacity-50' : ''
"
/>
</SettingsAccordion>
@@ -31,8 +31,6 @@ const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const disableBackupCode = ref('');
const useBackupCodeToDisable = ref(false);
const regenerateOtpCode = ref('');
// Utility functions
@@ -56,17 +54,10 @@ const downloadBackupCodes = () => {
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
otpCode: disableOtpCode.value,
});
};
const toggleDisableMethod = () => {
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
disableOtpCode.value = '';
disableBackupCode.value = '';
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
@@ -77,8 +68,6 @@ const handleRegenerateBackupCodes = async () => {
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableBackupCode.value = '';
useBackupCodeToDisable.value = false;
disableDialogRef.value?.close();
};
@@ -168,32 +157,12 @@ defineExpose({
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-if="!useBackupCodeToDisable"
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
<Input
v-else
v-model="disableBackupCode"
type="text"
maxlength="8"
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
/>
<Button
link
sm
type="button"
:label="
useBackupCodeToDisable
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
"
@click="toggleDisableMethod"
/>
</div>
</Dialog>
@@ -5,8 +5,6 @@ import { useRouter, useRoute } from 'vue-router';
import { parseBoolean } from '@chatwoot/utils';
import mfaAPI from 'dashboard/api/mfa';
import { useAlert } from 'dashboard/composables';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MfaStatusCard from './MfaStatusCard.vue';
import MfaSetupWizard from './MfaSetupWizard.vue';
import MfaManagementActions from './MfaManagementActions.vue';
@@ -97,7 +95,6 @@ const completeMfaSetup = () => {
mfaEnabled.value = true;
backupCodesGenerated.value = true;
showSetup.value = false;
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
};
@@ -107,13 +104,12 @@ const cancelSetup = () => {
};
// Disable MFA
const disableMfa = async ({ password, otpCode, backupCode }) => {
const disableMfa = async ({ password, otpCode }) => {
try {
await mfaAPI.disable(password, { otpCode, backupCode });
await mfaAPI.disable(password, otpCode);
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
@@ -127,7 +123,6 @@ const regenerateBackupCodes = async ({ otpCode }) => {
backupCodes.value = response.data.backup_codes;
managementActionsRef.value?.resetRegenerateForm();
managementActionsRef.value?.showBackupCodesDialog();
emitter.emit(BUS_EVENTS.MFA_STATE_CHANGED);
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
@@ -13,5 +13,4 @@ export const BUS_EVENTS = {
NEW_CONVERSATION_MODAL: 'newConversationModal',
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
MFA_STATE_CHANGED: 'MFA_STATE_CHANGED',
};
@@ -1,3 +1,5 @@
import { isApple } from './platform';
export const isEnter = e => {
return e.key === 'Enter';
};
@@ -14,13 +16,20 @@ export const hasPressedCommand = e => {
return e.metaKey;
};
// True when the platform's "command" modifier is held: Cmd (metaKey) on
// Apple platforms (macOS, iOS/iPadOS hardware keyboards), Ctrl (ctrlKey)
// elsewhere. Mirrors the `$mod` convention used by tinykeys and
// prosemirror-keymap so the editor and the app agree on what counts as the
// send modifier.
export const hasPressedMod = e => Boolean(isApple() ? e.metaKey : e.ctrlKey);
export const hasPressedEnterAndNotCmdOrShift = e => {
return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e);
return isEnter(e) && !hasPressedMod(e) && !hasPressedShift(e);
};
export const hasPressedCommandAndEnter = e => {
return hasPressedCommand(e) && isEnter(e);
};
// Detects the platform-aware "send" shortcut: Cmd+Enter on Apple platforms,
// Ctrl+Enter on Windows/Linux.
export const hasPressedCommandAndEnter = e => hasPressedMod(e) && isEnter(e);
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492
+50
View File
@@ -0,0 +1,50 @@
// Detects the current OS using the modern User-Agent Client Hints API,
// falling back to userAgent parsing on Safari/Firefox where it is unavailable.
// Treats iPad on iOS 13+ (which spoofs Macintosh) as iOS via maxTouchPoints.
export const OS = Object.freeze({
MAC: 'macos',
WINDOWS: 'windows',
LINUX: 'linux',
ANDROID: 'android',
IOS: 'ios',
UNKNOWN: 'unknown',
});
// navigator.userAgentData.platform → OS constant (lowercased keys)
const UAD_MAP = {
macos: OS.MAC,
windows: OS.WINDOWS,
linux: OS.LINUX,
android: OS.ANDROID,
ios: OS.IOS,
};
export function detectOS() {
if (typeof navigator === 'undefined') return OS.UNKNOWN;
// Trust userAgentData only when it maps to a known OS; otherwise fall
// through to UA parsing so unmapped values (e.g. "Chrome OS") don't leak.
const uad = navigator.userAgentData?.platform?.toLowerCase();
if (uad && UAD_MAP[uad]) return UAD_MAP[uad];
const ua = navigator.userAgent || '';
if (/android/i.test(ua)) return OS.ANDROID;
if (/iPhone|iPod/.test(ua)) return OS.IOS;
if (
/iPad/.test(ua) ||
(/Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1)
) {
return OS.IOS;
}
if (/Win/i.test(ua)) return OS.WINDOWS;
if (/Mac/i.test(ua)) return OS.MAC;
if (/Linux/i.test(ua)) return OS.LINUX;
return OS.UNKNOWN;
}
export const isApple = () => {
const os = detectOS();
return os === OS.MAC || os === OS.IOS;
};
@@ -3,9 +3,29 @@ import {
isEscape,
hasPressedShift,
hasPressedCommand,
hasPressedMod,
hasPressedCommandAndEnter,
hasPressedEnterAndNotCmdOrShift,
isActiveElementTypeable,
} from '../KeyboardHelpers';
const setNavigator = navigatorValue => {
Object.defineProperty(global, 'navigator', {
value: navigatorValue,
configurable: true,
writable: true,
});
};
const onMac = () => setNavigator({ userAgentData: { platform: 'macOS' } });
const onWindows = () =>
setNavigator({ userAgentData: { platform: 'Windows' } });
const onIOS = () =>
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
describe('#KeyboardHelpers', () => {
describe('#isEnter', () => {
it('return correct values', () => {
@@ -30,6 +50,112 @@ describe('#KeyboardHelpers', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
});
});
describe('#hasPressedMod', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('uses metaKey on macOS', () => {
onMac();
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
});
it('uses ctrlKey on Windows', () => {
onWindows();
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(true);
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(false);
});
it('uses metaKey on iOS hardware keyboards', () => {
onIOS();
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
});
it('returns false when no modifier is held', () => {
onWindows();
expect(hasPressedMod({ metaKey: false, ctrlKey: false })).toBe(false);
});
});
describe('#hasPressedCommandAndEnter', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('returns true for Cmd+Enter on macOS', () => {
onMac();
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
true
);
});
it('returns true for Ctrl+Enter on Windows (CW-6859 fix)', () => {
onWindows();
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
true
);
});
it('returns false for Ctrl+Enter on macOS (Mac uses Cmd, not Ctrl)', () => {
onMac();
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
false
);
});
it('returns true for Cmd+Enter on iOS hardware keyboards', () => {
onIOS();
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
true
);
});
it('returns false for plain Enter', () => {
onWindows();
expect(hasPressedCommandAndEnter({ key: 'Enter' })).toBe(false);
});
});
describe('#hasPressedEnterAndNotCmdOrShift', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('returns true for plain Enter on Windows', () => {
onWindows();
expect(hasPressedEnterAndNotCmdOrShift({ key: 'Enter' })).toBe(true);
});
it('returns false for Ctrl+Enter on Windows (mod is held)', () => {
onWindows();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', ctrlKey: true })
).toBe(false);
});
it('returns false for Cmd+Enter on macOS (mod is held)', () => {
onMac();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', metaKey: true })
).toBe(false);
});
it('returns false for Shift+Enter', () => {
onWindows();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', shiftKey: true })
).toBe(false);
});
});
});
describe('isActiveElementTypeable', () => {
@@ -0,0 +1,186 @@
import { detectOS, isApple, OS } from '../platform';
const setNavigator = ({ userAgentData, userAgent, maxTouchPoints } = {}) => {
Object.defineProperty(global, 'navigator', {
value: { userAgentData, userAgent, maxTouchPoints },
configurable: true,
writable: true,
});
};
describe('detectOS', () => {
const originalNavigator = global.navigator;
afterEach(() => {
Object.defineProperty(global, 'navigator', {
value: originalNavigator,
configurable: true,
writable: true,
});
});
describe('with userAgentData available', () => {
it('returns OS.MAC for macOS', () => {
setNavigator({ userAgentData: { platform: 'macOS' } });
expect(detectOS()).toBe(OS.MAC);
});
it('returns OS.WINDOWS for Windows', () => {
setNavigator({ userAgentData: { platform: 'Windows' } });
expect(detectOS()).toBe(OS.WINDOWS);
});
it('returns OS.LINUX for Linux', () => {
setNavigator({ userAgentData: { platform: 'Linux' } });
expect(detectOS()).toBe(OS.LINUX);
});
it('returns OS.ANDROID for Android', () => {
setNavigator({ userAgentData: { platform: 'Android' } });
expect(detectOS()).toBe(OS.ANDROID);
});
it('falls through to userAgent for unmapped values like "Chrome OS"', () => {
setNavigator({
userAgentData: { platform: 'Chrome OS' },
userAgent: 'Mozilla/5.0 (X11; CrOS x86_64) AppleWebKit/537.36',
});
// Not a mapped UAD value AND not a recognized UA pattern → unknown
expect(detectOS()).toBe(OS.UNKNOWN);
});
it('prefers userAgentData over userAgent when value is mapped', () => {
setNavigator({
userAgentData: { platform: 'Windows' },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
});
expect(detectOS()).toBe(OS.WINDOWS);
});
});
describe('with userAgent fallback', () => {
it('detects macOS from Safari userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
});
expect(detectOS()).toBe(OS.MAC);
});
it('detects Windows from userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.WINDOWS);
});
it('detects Linux from userAgent', () => {
setNavigator({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.LINUX);
});
it('detects Android from userAgent (before Linux match)', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.ANDROID);
});
it('detects iOS from iPhone userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
expect(detectOS()).toBe(OS.IOS);
});
it('detects iPadOS spoofing Macintosh via maxTouchPoints', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 5,
});
expect(detectOS()).toBe(OS.IOS);
});
it('returns OS.UNKNOWN when no match', () => {
setNavigator({ userAgent: 'SomeRandomBot/1.0' });
expect(detectOS()).toBe(OS.UNKNOWN);
});
it('returns OS.UNKNOWN when userAgent is missing', () => {
setNavigator({});
expect(detectOS()).toBe(OS.UNKNOWN);
});
});
describe('without navigator', () => {
it('returns OS.UNKNOWN when navigator is undefined', () => {
Object.defineProperty(global, 'navigator', {
value: undefined,
configurable: true,
writable: true,
});
expect(detectOS()).toBe(OS.UNKNOWN);
});
});
});
describe('isApple', () => {
const originalNavigator = global.navigator;
afterEach(() => {
Object.defineProperty(global, 'navigator', {
value: originalNavigator,
configurable: true,
writable: true,
});
});
it('returns true on macOS', () => {
setNavigator({ userAgentData: { platform: 'macOS' } });
expect(isApple()).toBe(true);
});
it('returns true on iOS (iPhone)', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
expect(isApple()).toBe(true);
});
it('returns true on iPadOS spoofing Macintosh', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 5,
});
expect(isApple()).toBe(true);
});
it('returns false on Windows', () => {
setNavigator({ userAgentData: { platform: 'Windows' } });
expect(isApple()).toBe(false);
});
it('returns false on Linux', () => {
setNavigator({ userAgentData: { platform: 'Linux' } });
expect(isApple()).toBe(false);
});
it('returns false on Android', () => {
setNavigator({ userAgentData: { platform: 'Android' } });
expect(isApple()).toBe(false);
});
});
describe('OS constants', () => {
it('is frozen so callers cannot mutate it', () => {
expect(Object.isFrozen(OS)).toBe(true);
});
});
+1 -1
View File
@@ -105,7 +105,7 @@ class ConversationReplyMailer < ApplicationMailer
end
def business_name
@inbox.business_name || @inbox.sanitized_name
@inbox.sanitized_business_name
end
def from_email
+13 -2
View File
@@ -102,7 +102,7 @@ class Inbox < ApplicationRecord
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
# REMOVES: Forbidden chars (\<>@"()) + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
@@ -110,6 +110,10 @@ class Inbox < ApplicationRecord
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sanitized_business_name
sanitize_raw_name(business_name) || sanitized_name
end
def sms?
channel_type == 'Channel::Sms'
end
@@ -209,8 +213,15 @@ class Inbox < ApplicationRecord
email? ? display_name_from_email : ''
end
def sanitize_raw_name(raw)
return nil if raw.blank?
result = apply_sanitization_rules(raw)
result.presence
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;()]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
-4
View File
@@ -78,10 +78,6 @@ class Mfa::ManagementService
user.otp_backup_codes.present?
end
def remaining_backup_codes_count
Array(user.otp_backup_codes).count { |code| code != 'XXXXXXXX' }
end
def mfa_enabled?
user.otp_required_for_login?
end
@@ -0,0 +1,160 @@
class Notification::PushTestService
pattr_initialize [:user!, :subscription_ids!, :title, :body]
DEFAULT_TITLE = '%<installation_name>s notification test'.freeze
DEFAULT_BODY = 'This is a test from our team to check notification delivery on your device. No action needed.'.freeze
def self.default_title
format(DEFAULT_TITLE, installation_name: GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot'))
end
def self.default_body
DEFAULT_BODY
end
def perform
selected_subscriptions.map { |subscription| test_send(subscription) }
end
private
def resolved_title
title.presence || self.class.default_title
end
def resolved_body
body.presence || self.class.default_body
end
def selected_subscriptions
user.notification_subscriptions.where(id: subscription_ids).order(:id)
end
def test_send(subscription)
if subscription.browser_push?
test_browser_push(subscription)
elsif subscription.fcm?
test_fcm(subscription)
else
result(subscription, subscription.subscription_type.to_s, :skipped, 'Unknown subscription type')
end
end
def test_browser_push(subscription)
return result(subscription, 'browser_push', :skipped, 'VAPID keys not configured') unless VapidService.public_key
WebPush.payload_send(**browser_push_payload(subscription))
result(subscription, 'browser_push', :success, 'Web push accepted by endpoint')
rescue StandardError => e
result(subscription, 'browser_push', :failure, "#{e.class.name}: #{e.message}")
end
def test_fcm(subscription)
if firebase_credentials_present?
test_fcm_direct(subscription)
elsif chatwoot_hub_enabled?
test_fcm_via_hub(subscription)
else
result(subscription, 'fcm', :skipped, 'No Firebase credentials and push relay disabled')
end
end
def test_fcm_direct(subscription)
fcm_service = Notification::FcmService.new(
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil),
GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
)
response = fcm_service.fcm_client.send_v1(fcm_options(subscription))
status_code = response[:status_code].to_i
status = status_code.between?(200, 299) ? :success : :failure
result(subscription, 'fcm', status, "HTTP #{status_code} — #{response[:body]}")
rescue StandardError => e
result(subscription, 'fcm', :failure, "#{e.class.name}: #{e.message}")
end
def test_fcm_via_hub(subscription)
response = ChatwootHub.send_push_with_response(fcm_options(subscription))
result(subscription, 'fcm_via_hub', :success, "HTTP #{response.code} — #{response.body}")
rescue RestClient::ExceptionWithResponse => e
result(subscription, 'fcm_via_hub', :failure, "HTTP #{e.response&.code} — #{e.response&.body}")
rescue StandardError => e
result(subscription, 'fcm_via_hub', :failure, "#{e.class.name}: #{e.message}")
end
def firebase_credentials_present?
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
end
def chatwoot_hub_enabled?
ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
end
def browser_push_payload(subscription)
{
message: JSON.generate(
title: resolved_title,
tag: "super_admin_test_#{Time.zone.now.to_i}",
url: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com')
),
endpoint: subscription.subscription_attributes['endpoint'],
p256dh: subscription.subscription_attributes['p256dh'],
auth: subscription.subscription_attributes['auth'],
vapid: {
subject: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'),
public_key: VapidService.public_key,
private_key: VapidService.private_key
},
ssl_timeout: 5,
open_timeout: 5,
read_timeout: 5
}
end
def fcm_options(subscription)
{
'token': subscription.subscription_attributes['push_token'],
'data': { payload: { data: { notification: { type: 'test' } } }.to_json },
'notification': { title: resolved_title, body: resolved_body },
'android': { priority: 'high' },
'apns': { payload: { aps: { sound: 'default', category: Time.zone.now.to_i.to_s } } },
'fcm_options': { analytics_label: 'SuperAdminTest' }
}
end
def result(subscription, type, status, message)
attrs = subscription.subscription_attributes || {}
{
id: subscription.id,
type: type.to_s,
device: device_label(subscription, attrs),
token_tail: token_tail(subscription, attrs),
status: status,
message: message
}
end
def device_label(subscription, attrs)
if subscription.browser_push?
endpoint_host(attrs['endpoint'].to_s)
else
attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—'
end
end
def endpoint_host(endpoint)
return '—' if endpoint.blank?
URI.parse(endpoint).host.presence || endpoint
rescue URI::InvalidURIError
endpoint
end
def token_tail(subscription, attrs)
if subscription.browser_push?
endpoint = attrs['endpoint'].to_s
endpoint.present? ? "…#{endpoint.last(6)}" : '—'
else
attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—'
end
end
end
@@ -1,6 +1,3 @@
json.feature_available Chatwoot.mfa_enabled?
json.enabled @user.mfa_enabled?
if Chatwoot.mfa_enabled?
json.backup_codes_generated @user.mfa_service.backup_codes_generated?
json.remaining_backup_codes @user.mfa_service.remaining_backup_codes_count
end
json.backup_codes_generated @user.mfa_service.backup_codes_generated? if Chatwoot.mfa_enabled?
@@ -32,7 +32,7 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings"].include? resource.resource %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
@@ -48,6 +48,7 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-mail-send-fill', url: super_admin_push_diagnostics_url, label: 'Push Diagnostics' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-logout-circle-r-line', url: super_admin_logout_url, label: 'Logout' } %>
</ul>
@@ -0,0 +1,190 @@
<% content_for(:title) do %>Push Diagnostics<% end %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title" id="page-title">Push Diagnostics</h1>
</header>
<section class="main-content__body">
<p class="text-sm text-slate-600 mb-6">
Send a test push notification to a specific user's registered devices to diagnose delivery issues.
Results show the raw FCM / Web Push / relay response so you can see exactly what failed.
</p>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">1. Look up user</h2>
<%= form_with url: super_admin_push_diagnostics_path, method: :get, local: true, class: 'flex gap-2 items-center' do |f| %>
<%= f.text_field :user_query,
value: @query,
placeholder: 'user@example.com or numeric user ID',
class: 'border border-slate-100 p-1.5 rounded-md w-80' %>
<%= f.submit 'Look up', class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer' %>
<% end %>
<% if @query.present? && @user.nil? %>
<p class="text-sm text-red-600 mt-2">No user found for "<%= @query %>".</p>
<% end %>
</div>
<% if @user %>
<div class="mb-6 border border-slate-100 rounded-md p-4 bg-slate-25">
<p class="text-sm">
<strong><%= @user.name %></strong>
&middot; <%= @user.email %>
&middot; ID <%= @user.id %>
</p>
<% if @user.accounts.any? %>
<p class="text-xs text-slate-500 mt-1">
Accounts:
<% @user.accounts.each_with_index do |account, index| %>
<%= ', ' if index.positive? %>
<%= account.name %> (ID <%= account.id %>)
<% end %>
</p>
<% end %>
</div>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">2. Select subscriptions (<%= @subscriptions.count %>)</h2>
<p class="text-xs text-amber-700 mb-3">
⚠️ This sends a real push to every selected device. Use only when diagnosing a reported issue.
</p>
<% if @subscriptions.empty? %>
<p class="text-sm text-slate-600">This user has no push subscriptions registered.</p>
<% else %>
<%= form_with url: super_admin_push_diagnostics_path, method: :post, local: true do |f| %>
<%= f.hidden_field :user_id, value: @user.id %>
<div class="mb-4 max-w-2xl">
<div class="mb-3">
<%= label_tag :push_title, 'Title', class: 'block text-xs font-medium text-slate-600 mb-1' %>
<%= text_field_tag :push_title,
params[:push_title].presence || Notification::PushTestService.default_title,
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
</div>
<div>
<%= label_tag :push_body, 'Body', class: 'block text-xs font-medium text-slate-600 mb-1' %>
<%= text_area_tag :push_body,
params[:push_body].presence || Notification::PushTestService.default_body,
rows: 2,
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
</div>
<p class="text-xs text-slate-400 mt-1">Customers will see this text as a real push notification on their device.</p>
</div>
<table class="w-full text-sm border border-slate-100 rounded-md">
<thead class="text-left text-xs text-slate-500 bg-slate-25">
<tr>
<th class="px-3 py-2 align-middle first:!pl-4 last:!pr-4"><input type="checkbox" id="toggle-all"></th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">ID</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device / endpoint</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device details</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Created</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Last updated</th>
</tr>
</thead>
<tbody>
<% @subscriptions.each do |sub| %>
<% attrs = (sub.subscription_attributes || {}).stringify_keys %>
<% if sub.browser_push? %>
<% endpoint = attrs['endpoint'].to_s %>
<% host = (begin; URI.parse(endpoint).host; rescue URI::InvalidURIError; nil; end) %>
<% device_display = host.presence || endpoint.presence || '—' %>
<% token_display = endpoint.present? ? "…#{endpoint.last(6)}" : '—' %>
<% else %>
<% device_display = attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—' %>
<% token_display = attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—' %>
<% end %>
<% extra_attrs = attrs.except('endpoint', 'p256dh', 'auth', 'push_token', 'device_id') %>
<tr class="border-t border-slate-100 align-middle">
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
<%= check_box_tag 'subscription_ids[]', sub.id, false, class: 'subscription-checkbox' %>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.id %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.subscription_type %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= device_display %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= token_display %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 text-xs align-top">
<% if extra_attrs.present? %>
<% extra_attrs.each do |k, v| %>
<div><span class="text-slate-500"><%= k %>:</span> <span class="font-mono"><%= v.to_s.truncate(40) %></span></div>
<% end %>
<% else %>
<span class="text-slate-400">—</span>
<% end %>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap"><%= sub.created_at.strftime('%Y-%m-%d %H:%M') %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap">
<%= sub.updated_at.strftime('%Y-%m-%d %H:%M') %>
<span class="text-xs text-slate-400">(<%= time_ago_in_words(sub.updated_at) %> ago)</span>
</td>
</tr>
<% end %>
</tbody>
</table>
<div class="mt-4 flex gap-2">
<%= f.submit 'Send Test Push to Selected',
class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
<%= submit_tag 'Delete Selected Subscriptions',
formaction: destroy_subscriptions_super_admin_push_diagnostics_path,
formmethod: 'post',
data: { confirm: "Delete the selected subscription(s)? The user won't receive pushes on those devices until their app re-registers." },
class: 'border border-red-200 bg-red-50 text-red-700 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
</div>
<% end %>
<% end %>
</div>
<% if @results.present? %>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">3. Results</h2>
<table class="w-full text-sm border border-slate-100 rounded-md">
<thead class="text-left text-xs text-slate-500 bg-slate-25">
<tr>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Sub ID</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Status</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Details</th>
</tr>
</thead>
<tbody>
<% @results.each do |r| %>
<tr class="border-t border-slate-100 align-top">
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:id] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:type] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:device] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:token_tail] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
<%
color = {
success: 'bg-green-100 text-green-800',
failure: 'bg-red-100 text-red-800',
skipped: 'bg-slate-100 text-slate-600'
}[r[:status]]
%>
<span class="px-2 py-0.5 rounded-full text-xs <%= color %>"><%= r[:status] %></span>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all whitespace-pre-wrap"><%= r[:message] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% end %>
</section>
<% content_for :javascript do %>
<script>
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('toggle-all');
if (!toggle) return;
toggle.addEventListener('change', (event) => {
document.querySelectorAll('.subscription-checkbox').forEach((cb) => {
cb.checked = event.target.checked;
});
});
});
</script>
<% end %>
+6
View File
@@ -496,3 +496,9 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
super_admin:
push_diagnostics:
user_not_found: 'User not found.'
no_subscriptions_to_test: 'Select at least one subscription to test.'
no_subscriptions_to_delete: 'Select at least one subscription to delete.'
subscriptions_deleted: "Deleted %{count} subscription(s). The user's device(s) will re-register on next app launch."
+3
View File
@@ -625,6 +625,9 @@ Rails.application.routes.draw do
root to: 'dashboard#index'
resource :app_config, only: [:show, :create]
resource :push_diagnostics, only: [:show, :create] do
post :destroy_subscriptions, on: :collection
end
# order of resources affect the order of sidebar navigation in super admin
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
@@ -52,9 +52,9 @@ class Internal::Accounts::InternalAttributesService
# Get list of valid features that can be manually managed
def valid_feature_list
# Business and Enterprise plan features only
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES +
%w[inbound_emails]
end
# Account notes functionality removed for now
@@ -2,9 +2,8 @@
# Get all feature names and their display names
all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names
# Business and Enterprise plan features only
premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
# Features that can be manually managed
premium_features = Internal::Accounts::InternalAttributesService.new(field.resource).valid_feature_list
# Get only premium features with display names
premium_features_with_display = premium_features.map do |feature|
+6 -2
View File
@@ -106,14 +106,18 @@ class ChatwootHub
end
def self.send_push(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
send_push_with_response(fcm_options)
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def self.send_push_with_response(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
end
def self.emit_event(event_name, event_data)
return if ENV['DISABLE_TELEMETRY']
@@ -4,25 +4,164 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do
include Rails.application.routes.url_helpers
describe 'POST /twilio/callback' do
let(:account) { create(:account) }
let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') }
let(:params) do
{
'From' => '+1234567890',
'To' => '+0987654321',
'To' => twilio_channel.phone_number,
'Body' => 'Test message',
'AccountSid' => 'AC123',
'SmsSid' => 'SM123'
}
end
it 'enqueues the Twilio events job' do
expect do
post twilio_callback_index_url, params: params
end.to have_enqueued_job(Webhooks::TwilioEventsJob).with(params)
def post_with_signature(url, params:)
validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token)
signature = validator.build_signature_for(url, params)
post url, params: params, headers: { 'X-Twilio-Signature' => signature }
end
it 'returns no content status' do
post twilio_callback_index_url, params: params
expect(response).to have_http_status(:no_content)
context 'with valid signature' do
it 'enqueues the Twilio events job' do
url = twilio_callback_index_url
expect do
post_with_signature(url, params: params)
end.to have_enqueued_job(Webhooks::TwilioEventsJob)
end
it 'returns no content status' do
url = twilio_callback_index_url
post_with_signature(url, params: params)
expect(response).to have_http_status(:no_content)
end
end
context 'with invalid signature' do
it 'returns forbidden status' do
post twilio_callback_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
expect(response).to have_http_status(:forbidden)
end
it 'does not enqueue the job' do
expect do
post twilio_callback_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
end.not_to have_enqueued_job(Webhooks::TwilioEventsJob)
end
end
context 'with missing signature header' do
it 'returns forbidden status' do
post twilio_callback_index_url, params: params
expect(response).to have_http_status(:forbidden)
end
end
context 'when channel is not found' do
it 'returns forbidden status' do
post twilio_callback_index_url, params: params.merge('AccountSid' => 'UNKNOWN', 'To' => '+0000000000')
expect(response).to have_http_status(:forbidden)
end
end
context 'when channel uses API key authentication' do
let(:twilio_channel) do
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123')
end
it 'skips signature validation and enqueues the job' do
expect do
post twilio_callback_index_url, params: params
end.to have_enqueued_job(Webhooks::TwilioEventsJob)
end
end
context 'when behind a reverse proxy with X-Forwarded-Proto' do
it 'validates signature against the HTTPS URL' do
http_url = twilio_callback_index_url
https_url = http_url.sub('http://', 'https://')
validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token)
signature = validator.build_signature_for(https_url, params)
post http_url, params: params, headers: {
'X-Twilio-Signature' => signature,
'X-Forwarded-Proto' => 'https'
}
expect(response).to have_http_status(:no_content)
end
it 'validates signature when forwarded proto is a comma-separated chain' do
http_url = twilio_callback_index_url
https_url = http_url.sub('http://', 'https://')
validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token)
signature = validator.build_signature_for(https_url, params)
post http_url, params: params, headers: {
'X-Twilio-Signature' => signature,
'X-Forwarded-Proto' => 'https,http'
}
expect(response).to have_http_status(:no_content)
end
end
context 'with MessagingServiceSid lookup' do
let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123') }
let(:params) do
{
'From' => '+1234567890',
'Body' => 'Test message',
'AccountSid' => 'AC123',
'SmsSid' => 'SM123',
'MessagingServiceSid' => twilio_channel.messaging_service_sid
}
end
it 'validates and enqueues the job' do
url = twilio_callback_index_url
post_with_signature(url, params: params)
expect(response).to have_http_status(:no_content)
end
end
context 'when MessagingServiceSid is present but does not match any channel' do
let(:params) do
{
'From' => '+1234567890',
'To' => twilio_channel.phone_number,
'Body' => 'Test message',
'AccountSid' => 'AC123',
'SmsSid' => 'SM123',
'MessagingServiceSid' => 'MG_UNKNOWN'
}
end
it 'falls back to phone number lookup and enqueues the job' do
url = twilio_callback_index_url
expect do
post_with_signature(url, params: params)
end.to have_enqueued_job(Webhooks::TwilioEventsJob)
expect(response).to have_http_status(:no_content)
end
end
context 'when MessagingServiceSid matches a channel but AccountSid does not' do
let(:other_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC_OTHER') }
let(:params) do
{
'From' => '+1234567890',
'To' => twilio_channel.phone_number,
'Body' => 'Test message',
'AccountSid' => 'AC123',
'SmsSid' => 'SM123',
'MessagingServiceSid' => other_channel.messaging_service_sid
}
end
it 'returns forbidden without falling back to phone number lookup' do
url = twilio_callback_index_url
post_with_signature(url, params: params)
expect(response).to have_http_status(:forbidden)
end
end
end
end
@@ -4,23 +4,132 @@ RSpec.describe 'Twilio::DeliveryStatusController', type: :request do
include Rails.application.routes.url_helpers
describe 'POST /twilio/delivery_status' do
let(:account) { create(:account) }
let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') }
let(:params) do
{
'MessageSid' => 'SM123',
'MessageStatus' => 'delivered',
'AccountSid' => 'AC123'
'AccountSid' => 'AC123',
'From' => twilio_channel.phone_number
}
end
it 'enqueues the Twilio delivery status job' do
expect do
post twilio_delivery_status_index_url, params: params
end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob).with(params)
def post_with_signature(url, params:, channel: twilio_channel)
validator = Twilio::Security::RequestValidator.new(channel.auth_token)
signature = validator.build_signature_for(url, params)
post url, params: params, headers: { 'X-Twilio-Signature' => signature }
end
it 'returns no content status' do
post twilio_delivery_status_index_url, params: params
expect(response).to have_http_status(:no_content)
context 'with valid signature' do
it 'enqueues the delivery status job' do
url = twilio_delivery_status_index_url
expect do
post_with_signature(url, params: params)
end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
end
it 'returns no content status' do
url = twilio_delivery_status_index_url
post_with_signature(url, params: params)
expect(response).to have_http_status(:no_content)
end
end
context 'with invalid signature' do
it 'returns forbidden status' do
post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
expect(response).to have_http_status(:forbidden)
end
it 'does not enqueue the job' do
expect do
post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
end.not_to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
end
end
context 'with missing signature header' do
it 'returns forbidden status' do
post twilio_delivery_status_index_url, params: params
expect(response).to have_http_status(:forbidden)
end
end
context 'when channel uses API key authentication' do
let(:twilio_channel) do
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123')
end
it 'skips signature validation and enqueues the job' do
expect do
post twilio_delivery_status_index_url, params: params
end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
end
end
context 'with MessagingServiceSid lookup' do
let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123') }
let(:params) do
{
'MessageSid' => 'SM123',
'MessageStatus' => 'delivered',
'AccountSid' => 'AC123',
'MessagingServiceSid' => twilio_channel.messaging_service_sid
}
end
it 'validates and enqueues the job' do
url = twilio_delivery_status_index_url
post_with_signature(url, params: params)
expect(response).to have_http_status(:no_content)
end
end
context 'when To does not map to a channel but From does' do
let(:params) do
{
'MessageSid' => 'SM123',
'MessageStatus' => 'delivered',
'AccountSid' => 'AC123',
'To' => '+19999999999',
'From' => twilio_channel.phone_number
}
end
it 'falls back to From lookup and enqueues the delivery status job' do
url = twilio_delivery_status_index_url
expect do
post_with_signature(url, params: params)
end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
expect(response).to have_http_status(:no_content)
end
end
context 'when To maps to an API-key channel but From maps to a different channel' do
let!(:api_key_channel) do
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123')
end
let!(:from_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') }
let(:params) do
{
'MessageSid' => 'SM123',
'MessageStatus' => 'delivered',
'AccountSid' => 'AC123',
'To' => api_key_channel.phone_number,
'From' => from_channel.phone_number
}
end
it 'rejects invalid signatures instead of skipping verification' do
expect do
post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
end.not_to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
expect(response).to have_http_status(:forbidden)
end
end
end
end
@@ -205,23 +205,6 @@ RSpec.describe 'MFA API', type: :request do
expect(json_response['error']).to include('Invalid')
end
end
context 'with valid password and backup code' do
it 'disables 2FA successfully' do
backup_code = user.otp_backup_codes.first
delete '/api/v1/profile/mfa',
params: { password: 'Test@123456', backup_code: backup_code },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
user.reload
expect(user.otp_required_for_login).to be_falsey
expect(user.otp_secret).to be_nil
expect(user.otp_backup_codes).to be_blank
end
end
end
context 'when 2FA is not enabled' do
+50 -1
View File
@@ -148,10 +148,59 @@ export const icons = {
width: 7,
height: 11,
},
'empty-assignee': {
body: `<g fill="none" stroke="currentColor">
<path d="M10.29 3.16394C11.4196 2.94535 12.5805 2.94535 13.71 3.16394" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.71 20.8362C12.5805 21.0548 11.4196 21.0548 10.29 20.8362" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.0479 4.54907C18.0032 5.19637 18.8251 6.02134 19.4688 6.97906" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.16394 13.7101C2.94535 12.5806 2.94535 11.4196 3.16394 10.2901" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.4515 17.0482C18.8042 18.0035 17.9792 18.8254 17.0215 19.4691" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.8359 10.2901C21.0545 11.4196 21.0545 12.5806 20.8359 13.7101" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.54883 6.95205C5.19612 5.99673 6.0211 5.17481 6.97881 4.53107" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.95223 19.4512C5.99692 18.8039 5.175 17.9789 4.53125 17.0212" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 14.25C13.7259 14.25 15.125 12.8509 15.125 11.125C15.125 9.39911 13.7259 8 12 8C10.2741 8 8.875 9.39911 8.875 11.125C8.875 12.8509 10.2741 14.25 12 14.25Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 19.25C17 17.9239 16.4732 16.6521 15.5355 15.7144C14.5979 14.7767 13.3261 14.25 12 14.25C10.6739 14.25 9.40215 14.7767 8.46447 15.7144C7.52678 16.6521 7 17.9239 7 19.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g>`,
width: 24,
height: 24,
},
party: {
body: `<g fill="currentColor" stroke="none"><path d="M8.023 2.426a.6.6 0 0 1 .4.748l-.562 1.85a.6.6 0 0 1-1.15-.348l.563-1.85a.6.6 0 0 1 .75-.4M12.8 3a.6.6 0 1 0-1.2 0v.2h-.2a.6.6 0 0 0 0 1.2h.2v.2a.6.6 0 0 0 1.2 0v-.2h.2a.6.6 0 1 0 0-1.2h-.2z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.645 6.197a1.4 1.4 0 0 0-2.268.416L2.703 10.35a2.2 2.2 0 0 0 .452 2.456l.04.04a2.2 2.2 0 0 0 2.457.452l3.734-1.675a1.4 1.4 0 0 0 .417-2.267zm-1.172.908a.2.2 0 0 1 .324-.06l3.158 3.158a.2.2 0 0 1-.06.324l-3.734 1.674a1 1 0 0 1-1.116-.205l-.041-.04a1 1 0 0 1-.206-1.117z"/><path d="M4.6 2.4a.6.6 0 0 1 .6.6v.2h.2a.6.6 0 0 1 0 1.2h-.2v.2a.6.6 0 0 1-1.2 0v-.2h-.2a.6.6 0 1 1 0-1.2H4V3a.6.6 0 0 1 .6-.6m8.2 8.2a.6.6 0 0 0-1.2 0v.2h-.2a.6.6 0 1 0 0 1.2h.2v.2a.6.6 0 0 0 1.2 0V12h.2a.6.6 0 1 0 0-1.2h-.2zm-1.976-4.576a.6.6 0 0 0-.848-.848l-.8.8a.6.6 0 0 0 .848.848zm2.438 2.802a.6.6 0 1 0-.33-1.154l-1.874.538a.6.6 0 1 0 .33 1.153z"/></g>`,
width: 16,
height: 16,
},
'expand-list': {
body: `<g fill="none" stroke="currentColor"><path d="M1.333 12V4a2 2 0 0 1 2-2h1.334a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H3.333a2 2 0 0 1-2-2Z" stroke-width="1.2"/><path d="M10.667 2H12a2.667 2.667 0 0 1 2.667 2.667v6.666A2.667 2.667 0 0 1 12 14h-1.333m-4-6H12m0 0-2-2m2 2-2 2" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></g>`,
width: 16,
height: 16,
},
hash: {
body: `<g fill="currentColor" stroke="none"><path d="M11.8125 8.3125H9.1875V5.6875H11.8125C11.9285 5.6875 12.0398 5.64141 12.1219 5.55936C12.2039 5.47731 12.25 5.36603 12.25 5.25C12.25 5.13397 12.2039 5.02269 12.1219 4.94064C12.0398 4.85859 11.9285 4.8125 11.8125 4.8125H9.1875V2.1875C9.1875 2.07147 9.14141 1.96019 9.05936 1.87814C8.97731 1.79609 8.86603 1.75 8.75 1.75C8.63397 1.75 8.52269 1.79609 8.44064 1.87814C8.35859 1.96019 8.3125 2.07147 8.3125 2.1875V4.8125H5.6875V2.1875C5.6875 2.07147 5.64141 1.96019 5.55936 1.87814C5.47731 1.79609 5.36603 1.75 5.25 1.75C5.13397 1.75 5.02269 1.79609 4.94064 1.87814C4.85859 1.96019 4.8125 2.07147 4.8125 2.1875V4.8125H2.1875C2.07147 4.8125 1.96019 4.85859 1.87814 4.94064C1.79609 5.02269 1.75 5.13397 1.75 5.25C1.75 5.36603 1.79609 5.47731 1.87814 5.55936C1.96019 5.64141 2.07147 5.6875 2.1875 5.6875H4.8125V8.3125H2.1875C2.07147 8.3125 1.96019 8.35859 1.87814 8.44064C1.79609 8.52269 1.75 8.63397 1.75 8.75C1.75 8.86603 1.79609 8.97731 1.87814 9.05936C1.96019 9.14141 2.07147 9.1875 2.1875 9.1875H4.8125V11.8125C4.8125 11.9285 4.85859 12.0398 4.94064 12.1219C5.02269 12.2039 5.13397 12.25 5.25 12.25C5.36603 12.25 5.47731 12.2039 5.55936 12.1219C5.64141 12.0398 5.6875 11.9285 5.6875 11.8125V9.1875H8.3125V11.8125C8.3125 11.9285 8.35859 12.0398 8.44064 12.1219C8.52269 12.2039 8.63397 12.25 8.75 12.25C8.86603 12.25 8.97731 12.2039 9.05936 12.1219C9.14141 12.0398 9.1875 11.9285 9.1875 11.8125V9.1875H11.8125C11.9285 9.1875 12.0398 9.14141 12.1219 9.05936C12.2039 8.97731 12.25 8.86603 12.25 8.75C12.25 8.63397 12.2039 8.52269 12.1219 8.44064C12.0398 8.35859 11.9285 8.3125 11.8125 8.3125ZM5.6875 8.3125V5.6875H8.3125V8.3125H5.6875Z" fill="currentColor"/></g>`,
width: 14,
height: 14,
},
/** Conversation Status Starts */
'status-empty': {
body: `<path d="M12 3a9 9 0 0 1 6.642 15.075" stroke="#60646c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.7 12 12 9.3 9.3 12m2.7 2.7V9.3" stroke="#60646c" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.45 9.188a9 9 0 0 0-.45 2.7m.747 3.712a9 9 0 0 0 2.187 3.06M5.372 5.911a9 9 0 0 1 .802-.77M8.98 20.478a9 9 0 0 0 6.867-.342" stroke="#60646c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 24,
height: 24,
},
'status-pending': {
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#d9d9d9" d="M0 0h24v24H0z"/></mask><g mask="url(#a)" stroke="#b9bbc6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.164a9 9 0 0 1 3.42 0m0 17.672a9 9 0 0 1-3.42 0M17.048 4.55a9 9 0 0 1 2.42 2.43M3.164 13.71a9 9 0 0 1 0-3.42m16.288 6.758a9 9 0 0 1-2.43 2.421m3.814-9.179a9 9 0 0 1 0 3.42M4.549 6.952a9 9 0 0 1 2.43-2.421m-.027 14.92a9 9 0 0 1-2.42-2.43"/></g>`,
width: 24,
height: 24,
},
'status-open': {
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#d9d9d9" d="M0 0h24v24H0z"/></mask><g mask="url(#a)"><path d="M4.188 18.25q2.187 2.724 5.612 3.5a.89.89 0 0 0 .837-.176.99.99 0 0 0 .363-.8q0-.35-.2-.625a.88.88 0 0 0-.55-.35q-2.75-.625-4.5-2.8T4 12t1.75-5 4.5-2.8a.88.88 0 0 0 .55-.35q.2-.275.2-.625 0-.5-.363-.8A.89.89 0 0 0 9.8 2.25q-3.424.776-5.613 3.5Q2 8.474 2 12t2.188 6.25m9.175 3.324q.361.3.837.175.825-.2 1.613-.525.787-.325 1.512-.775a.99.99 0 0 0 .45-.737.96.96 0 0 0-.3-.813 1.02 1.02 0 0 0-.588-.3.8.8 0 0 0-.612.15q-.575.375-1.212.638a8 8 0 0 1-1.313.412.88.88 0 0 0-.55.35q-.2.275-.2.625 0 .5.363.8m5.237-4.662q.05.337.3.587a.96.96 0 0 0 .813.3 1 1 0 0 0 .737-.45q.45-.724.775-1.512a11 11 0 0 0 .525-1.613.89.89 0 0 0-.175-.837.99.99 0 0 0-.8-.363q-.35 0-.625.188a.83.83 0 0 0-.35.537 8.4 8.4 0 0 1-.413 1.325 7 7 0 0 1-.637 1.225.8.8 0 0 0-.15.613m1.55-6.112q.275.199.625.2.5 0 .8-.363t.175-.838a11 11 0 0 0-.525-1.612 10.4 10.4 0 0 0-.775-1.513q-.275-.425-.738-.475a.96.96 0 0 0-.812.3 1.02 1.02 0 0 0-.3.588.88.88 0 0 0 .15.637q.375.575.637 1.213.263.637.413 1.312.075.35.35.55m-5.087-6.187q.638.262 1.212.637.3.201.638.15.336-.05.587-.3a.96.96 0 0 0 .3-.812q-.05-.463-.475-.738a10.4 10.4 0 0 0-1.512-.775A11 11 0 0 0 14.2 2.25a.89.89 0 0 0-.837.175.99.99 0 0 0-.363.8q0 .35.2.625t.55.35q.674.15 1.313.413M11.913 7" fill="#2781f6"/><path d="M12.001 12.902a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8" stroke="#2781f6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></g>`,
width: 24,
height: 24,
},
'status-snoozed': {
body: `<path d="M10 10H14.5L10 14.5H14.5" fill="none" stroke="#FFBA1A" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.1875 18.2492C5.64583 20.0658 7.51667 21.2325 9.8 21.7492C10.1167 21.8325 10.3958 21.7742 10.6375 21.5742C10.8792 21.3742 11 21.1075 11 20.7742C11 20.5408 10.9333 20.3325 10.8 20.1492C10.6667 19.9658 10.4833 19.8492 10.25 19.7992C8.41667 19.3825 6.91667 18.4492 5.75 16.9992C4.58333 15.5492 4 13.8825 4 11.9992C4 10.1158 4.58333 8.44917 5.75 6.99917C6.91667 5.54917 8.41667 4.61583 10.25 4.19917C10.4833 4.14917 10.6667 4.0325 10.8 3.84917C10.9333 3.66583 11 3.4575 11 3.22417C11 2.89083 10.8792 2.62417 10.6375 2.42417C10.3958 2.22417 10.1167 2.16583 9.8 2.24917C7.51667 2.76583 5.64583 3.9325 4.1875 5.74917C2.72917 7.56583 2 9.64917 2 11.9992C2 14.3492 2.72917 16.4325 4.1875 18.2492Z" fill="#FFBA1A"/><path d="M13.3625 21.5742C13.6042 21.7742 13.8833 21.8325 14.2 21.7492C14.75 21.6158 15.2875 21.4408 15.8125 21.2242C16.3375 21.0075 16.8417 20.7492 17.325 20.4492C17.5917 20.2658 17.7417 20.02 17.775 19.7117C17.8083 19.4033 17.7083 19.1325 17.475 18.8992C17.3083 18.7325 17.1125 18.6325 16.8875 18.5992C16.6625 18.5658 16.4583 18.6158 16.275 18.7492C15.8917 18.9992 15.4875 19.2117 15.0625 19.3867C14.6375 19.5617 14.2 19.6992 13.75 19.7992C13.5167 19.8492 13.3333 19.9658 13.2 20.1492C13.0667 20.3325 13 20.5408 13 20.7742C13 21.1075 13.1208 21.3742 13.3625 21.5742Z" fill="#FFBA1A"/><path d="M18.6 16.9117C18.6333 17.1367 18.7333 17.3325 18.9 17.4992C19.1333 17.7325 19.4042 17.8325 19.7125 17.7992C20.0208 17.7658 20.2667 17.6158 20.45 17.3492C20.75 16.8658 21.0083 16.3617 21.225 15.8367C21.4417 15.3117 21.6167 14.7742 21.75 14.2242C21.8333 13.9075 21.775 13.6283 21.575 13.3867C21.375 13.145 21.1083 13.0242 20.775 13.0242C20.5417 13.0242 20.3333 13.0867 20.15 13.2117C19.9667 13.3367 19.85 13.5158 19.8 13.7492C19.7 14.1992 19.5625 14.6408 19.3875 15.0742C19.2125 15.5075 19 15.9158 18.75 16.2992C18.6167 16.4825 18.5667 16.6867 18.6 16.9117Z" fill="#FFBA1A"/><path d="M20.15 10.7992C20.3333 10.9325 20.5417 10.9992 20.775 10.9992C21.1083 10.9992 21.375 10.8783 21.575 10.6367C21.775 10.395 21.8333 10.1158 21.75 9.79917C21.6167 9.24917 21.4417 8.71167 21.225 8.18667C21.0083 7.66167 20.75 7.1575 20.45 6.67417C20.2667 6.39083 20.0208 6.2325 19.7125 6.19917C19.4042 6.16583 19.1333 6.26583 18.9 6.49917C18.7333 6.66583 18.6333 6.86167 18.6 7.08667C18.5667 7.31167 18.6167 7.52417 18.75 7.72417C19 8.1075 19.2125 8.51167 19.3875 8.93667C19.5625 9.36167 19.7 9.79917 19.8 10.2492C19.85 10.4825 19.9667 10.6658 20.15 10.7992Z" fill="#FFBA1A"/><path d="M15.0625 4.61167C15.4875 4.78667 15.8917 4.99917 16.275 5.24917C16.475 5.3825 16.6875 5.4325 16.9125 5.39917C17.1375 5.36583 17.3333 5.26583 17.5 5.09917C17.7333 4.86583 17.8333 4.595 17.8 4.28667C17.7667 3.97833 17.6083 3.7325 17.325 3.54917C16.8417 3.24917 16.3375 2.99083 15.8125 2.77417C15.2875 2.5575 14.75 2.3825 14.2 2.24917C13.8833 2.16583 13.6042 2.22417 13.3625 2.42417C13.1208 2.62417 13 2.89083 13 3.22417C13 3.4575 13.0667 3.66583 13.2 3.84917C13.3333 4.0325 13.5167 4.14917 13.75 4.19917C14.2 4.29917 14.6375 4.43667 15.0625 4.61167Z" fill="#FFBA1A"/><path d="M11.9128 7.00075C11.9418 7.00025 11.9709 7 12 7C12.0072 7 12.0144 7.00002 12.0215 7.00005C12.0062 6.99946 11.9907 6.99917 11.975 6.99917C11.954 6.99917 11.9333 6.99969 11.9128 7.00075Z" fill="#FFBA1A"/>`,
width: 24,
height: 24,
},
'status-resolved': {
body: `<path d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#0D9B8A" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M9.2998 12L11.0998 13.8L14.6998 10.2" stroke="#0D9B8A" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
width: 24,
height: 24,
},
/** Ends */
/** Conversation Priority Starts */
'priority-empty': {
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#d9d9d9" d="M0 0h24v24H0z"/></mask><g mask="url(#a)" fill="#d9d9d9"><rect x="4" y="12" width="4" height="8" rx="2"/><rect x="10" y="8" width="4" height="12" rx="2"/><rect x="16" y="4" width="4" height="16" rx="2"/></g>`,
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="currentColor" d="M0 0h24v24H0z"/></mask><g mask="url(#a)" fill="currentColor"><rect x="4" y="12" width="4" height="8" rx="2"/><rect x="10" y="8" width="4" height="12" rx="2"/><rect x="16" y="4" width="4" height="16" rx="2"/></g>`,
width: 24,
height: 24,
},