Compare commits

..
Author SHA1 Message Date
Shivam Mishra cb9c3af222 fix: orientation 2025-01-13 20:34:11 +05:30
Shivam Mishra d0fd6e1e67 fix: reply box banner 2025-01-13 20:32:45 +05:30
Shivam Mishra 60e8b88c75 feat: update orientation of error message 2025-01-13 20:29:27 +05:30
Sojan JoseandGitHub c75ed1ef69 fix: Prevent duplicate notifications for mentions (#10675)
This PR addresses an issue where mention_notification was being
overwritten by assigned_conversation_new_message notifications.
Similarly, participating_conversation_new_message was taking priority
over assigned_conversation_new_message.

The fix ensures that additional notifications are not created in these
scenarios by establishing a priority order for notifications as follows:

1. mention_notification
2. assigned_conversation_new_message
3. participating_conversation_new_message

This change maintains consistency and prevents redundant notifications.

----

Note: In the older implementation of notifications in Chatwoot,
notification objects were created only if the user had subscribed to a
specific notification type. With the new inbox_view model, we are
changing this approach to create notification objects in all cases. This
PR updates the services to remove reliance on notification preferences,
simplifying the logic.


----

Caveat: Since we don’t create extra notifications for new messages if a
mention_notification already exists, a user subscribed to
new_message_notification push/email notifications but not to
mention_notification will not receive notifications in these cases.
2025-01-13 11:20:31 +05:30
Sojan JoseandGitHub 97235a4365 fix: Exclude notifications for blocked contacts, except mentions (#10659)
- Prevents notifications from being created for conversations or actions
involving blocked contacts.
- The exception is the conversation_mention notification type, which
will still be created when applicable.
2025-01-12 15:30:27 +05:30
Shivam MishraandGitHub 74870bec41 fix: Fix bubble overflow issue and color (#10674) 2025-01-11 10:02:24 -08:00
Shivam MishraandGitHub 0d90e7c158 fix: Teams feature flag (#10673) 2025-01-11 10:01:57 -08:00
Shivam MishraandGitHub e70f7a2550 fix: Bubble typography (#10653)
![CleanShot 2025-01-08 at 18 22
36@2x](https://github.com/user-attachments/assets/77cc99d2-1bdf-49ff-9016-57bb9c50b149)
2025-01-10 15:40:12 +05:30
Shivam MishraandGitHub 4912140893 fix: ensure the message types are same when grouping (#10661) 2025-01-10 09:32:06 +05:30
Shivam MishraandGitHub 2d44468c38 fix: attachment url in file chip (#10662)
https://github.com/user-attachments/assets/e84d94d5-876d-4c00-88be-a460f917bf3b
2025-01-10 09:31:56 +05:30
Shivam MishraandGitHub 1ac2a0949f fix: conversation footer colors (#10654) 2025-01-09 15:04:40 +05:30
Shivam MishraandGitHub ef01779d61 fix: Message grouping logic (#10656) 2025-01-09 13:24:16 +05:30
Muhsin KelothandGitHub b5f4e8155b chore: Configure iOS universal linking (#10651)
- Moved `apple-app-site-association` to
`.well-known/apple-app-site-association`
https://docs.expo.dev/linking/ios-universal-links/#create-aasa-file
- Updated the paths pattern to accept conversation links only.
2025-01-08 21:25:11 +05:30
Shivam MishraandGitHub 25258417de fix: v4 ui issues (#10652)
This PR has the following fixes

1. Remove leading `:` in inbox view push message content
2. Styles for block quotes in text
3. Better condition for checking bot message
4. Use dompurify
2025-01-08 18:17:50 +05:30
dd595675bc chore: Update reply editor design (#10575)
# Pull Request Template

## Description

This PR will update the reply message editor’s design.

**Screen recording**


https://github.com/user-attachments/assets/40f61903-6bf7-4031-9a36-9027dffc46aa

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-01-08 17:49:53 +05:30
fa8f1d9b6f fix: throttle reports api endpoint (#10620)
- Throttle reports API requests at the account level
- Throttle reports API requests at the user level for dashboard users as
well as API users

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-01-08 15:43:33 +05:30
Shivam MishraandGitHub 91b1015457 feat: fixes for bubble UI (#10643)
This PR applies the following fixes

1. Images in emails have margins, causing unnecessary gaps when 1x1px
images are loaded (ref:
[42111](https://app.chatwoot.com/app/accounts/1/conversations/42111?messageId=96215315))
2. Two adjacent `<br>` tags would add a huge gap, fixed this (ref:
[42111](https://app.chatwoot.com/app/accounts/1/conversations/42111?messageId=96215315))
3. Color for outgoing emails is wrong (ref:
[41621](https://app.chatwoot.com/app/accounts/1/conversations/41621?messageId=93560032))
4. Wrong list styles (see:
[42372](https://app.chatwoot.com/app/accounts/1/conversations/42372?messageId=96208130))
5. Wrong bubble color when outgoing message is sent by a bot or captain
6. Wrong avatar when outgoing message is sent by a bot or captain
2025-01-08 09:26:40 +05:30
Shivam MishraandGitHub c924d386f4 fix: reply-to preview not working on next bubbles (#10649) 2025-01-08 08:58:21 +05:30
Sivin VargheseandGitHub 3c93cdb8b2 feat(v4): Update Inbox view card design (#10599) 2025-01-07 21:11:54 +05:30
918f8e6f8e fix: AgentTable report showing test value for zero conversations (#10641)
# Pull Request Template

## Description

Changed to make use of nullish coalescing operator to only short circuit
in cases when the `metric` variable is not zero. This change also begs
the question as to whether the `stringToFloat` test function should
exist - to me it seems interesting to have test code embedded into
production code for the frontend?
Fixes #10640

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Tested by opening the report overview page and reviewing that this shows
`---` for agents with no conversations assigned to them.


## 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
- [x] I have made corresponding changes to the documentation
- [x] 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-01-06 13:51:27 +05:30
Sojan JoseandGitHub 5e4e955e64 chore: Avoid contact inboxes getting deleted (#10612)
We observed some rare instances on prod where a contact inbox was
getting deleted during the contact merge action. the potential suspect
is contact inbox objects already loaded into the memory getting cleared
through the destroy association job. Reloading the object to avoid this
case !!!
2024-12-26 16:17:38 +05:30
69 changed files with 1119 additions and 972 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ class ContactMergeAction
# attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
@mergee_contact.destroy!
@mergee_contact.reload.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes)
+2
View File
@@ -25,6 +25,8 @@ class NotificationBuilder
def build_notification
# Create conversation_creation notification only if user is subscribed to it
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
user.notifications.create!(
notification_type: notification_type,
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
@@ -20,6 +20,8 @@ const props = defineProps({
const { t } = useI18n();
const slaCardLabelRef = ref(null);
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
@@ -45,7 +47,15 @@ const unreadMessagesCount = computed(() => {
return unreadCount;
});
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
const hasSlaThreshold = computed(() => {
return (
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
);
});
defineExpose({
hasSlaThreshold,
});
</script>
<template>
@@ -73,7 +83,11 @@ const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
: 'grid-cols-[1fr_20px]'
"
>
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
<SLACardLabel
v-show="hasSlaThreshold"
ref="slaCardLabelRef"
:conversation="conversation"
/>
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
<div class="overflow-hidden">
<CardLabels
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useRouter, useRoute } from 'vue-router';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
@@ -33,6 +33,8 @@ const props = defineProps({
const router = useRouter();
const route = useRoute();
const cardMessagePreviewWithMetaRef = ref(null);
const currentContact = computed(() => props.contact);
const currentContactName = computed(() => currentContact.value?.name);
@@ -56,8 +58,10 @@ const lastActivityAt = computed(() => {
});
const showMessagePreviewWithoutMeta = computed(() => {
const { slaPolicyId, labels = [] } = props.conversation;
return !slaPolicyId && labels.length === 0;
const { labels = [] } = props.conversation;
return (
!cardMessagePreviewWithMetaRef.value?.hasSlaThreshold && labels.length === 0
);
});
const onCardClick = e => {
@@ -82,6 +86,7 @@ const onCardClick = e => {
<template>
<div
role="button"
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
@click="onCardClick"
>
@@ -114,11 +119,12 @@ const onCardClick = e => {
</div>
</div>
<CardMessagePreview
v-if="showMessagePreviewWithoutMeta"
v-show="showMessagePreviewWithoutMeta"
:conversation="conversation"
/>
<CardMessagePreviewWithMeta
v-else
v-show="!showMessagePreviewWithoutMeta"
ref="cardMessagePreviewWithMetaRef"
:conversation="conversation"
:account-labels="accountLabels"
/>
@@ -31,13 +31,17 @@ const convertObjectCamelCaseToSnakeCase = object => {
const appliedSLA = computed(() => props.conversation?.appliedSla);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
return slaStatus.value?.threshold && appliedSLA.value?.id;
});
const slaStatusText = computed(() => {
return slaStatus.value?.type?.toUpperCase();
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
chat: props.conversation,
});
};
@@ -61,6 +65,21 @@ onUnmounted(() => {
});
watch(() => props.conversation, updateSlaStatus);
// This expose is to provide context to the parent component, so that it can decided weather
// a new row has to be added to the conversation card or not
// SLACardLabel > CardMessagePreviewWithMeta > ConversationCard
//
// We need to do this becuase each SLA card has it's own SLA timer
// and it's just convenient to have this logic in the SLACardLabel component
// However this is a bit hacky, and we should change this in the future
//
// TODO: A better implementation would be to have the timer as a shared composable, just like the provider pattern
// we use across the next components. Have the calculation be done on the top ConversationCard component
// and then the value be injected to the SLACardLabel component
defineExpose({
hasSlaThreshold,
});
</script>
<template>
@@ -0,0 +1,248 @@
<script setup>
import { computed, ref, onBeforeMount } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import {
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
} from 'dashboard/helper/snoozeHelpers';
import { NOTIFICATION_TYPES_MAPPING } from 'dashboard/routes/dashboard/inbox/helpers/InboxViewHelpers';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
import InboxContextMenu from 'dashboard/routes/dashboard/inbox/components/InboxContextMenu.vue';
const props = defineProps({
inboxItem: { type: Object, default: () => ({}) },
stateInbox: { type: Object, default: () => ({}) },
});
const emit = defineEmits([
'click',
'contextMenuOpen',
'contextMenuClose',
'markNotificationAsRead',
'markNotificationAsUnRead',
'deleteNotification',
]);
const { t } = useI18n();
const isContextMenuOpen = ref(false);
const contextMenuPosition = ref({ x: null, y: null });
const slaCardLabel = ref(null);
const getMessageClasses = {
emphasis: 'text-sm font-medium text-n-slate-11',
emphasisUnread: 'text-sm font-medium text-n-slate-12',
normal: 'text-sm font-normal text-n-slate-11',
normalUnread: 'text-sm text-n-slate-12',
};
const primaryActor = computed(() => props.inboxItem?.primaryActor);
const meta = computed(() => primaryActor.value?.meta);
const assigneeMeta = computed(() => meta.value?.sender);
const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
});
const hasSlaThreshold = computed(() => {
return slaCardLabel.value?.hasSlaThreshold && primaryActor.value?.slaPolicyId;
});
const lastActivityAt = computed(() => {
const timestamp = props.inboxItem?.lastActivityAt;
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
});
const menuItems = computed(() => [
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
{
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
},
]);
const messageClasses = computed(() => ({
emphasis: isUnread.value
? getMessageClasses.emphasisUnread
: getMessageClasses.emphasis,
normal: isUnread.value
? getMessageClasses.normalUnread
: getMessageClasses.normal,
}));
const formatPushMessage = message => {
if (message.startsWith(': ')) {
return message.slice(2);
}
return message.replace(/^([^:]+):/g, (match, name) => {
return `<span class="${messageClasses.value.emphasis}">${name}:</span>`;
});
};
const formattedMessage = computed(() => {
const messageContent = `<span class="${messageClasses.value.normal}">${formatPushMessage(props.inboxItem?.pushMessageBody || '')}</span>`;
return isUnread.value
? `<span class="inline-flex flex-shrink-0 w-2 h-2 mb-px rounded-full bg-n-iris-10 ltr:mr-1 rtl:ml-1"></span> ${messageContent}`
: messageContent;
});
const notificationDetails = computed(() => {
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
const [icon = '', color = 'text-n-blue-text'] =
NOTIFICATION_TYPES_MAPPING[type] || [];
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
});
const snoozedUntilTime = computed(() => {
const { snoozedUntil } = props.inboxItem;
if (!snoozedUntil) return null;
return shortenSnoozeTime(
dynamicTime(snoozedReopenTimeToTimestamp(snoozedUntil))
);
});
const hasLastSnoozed = computed(() => props.inboxItem?.meta?.lastSnoozedAt);
const snoozedText = computed(() => {
return !hasLastSnoozed.value
? t('INBOX.TYPES_NEXT.SNOOZED_UNTIL', {
time: shortTimestamp(snoozedUntilTime.value),
})
: t('INBOX.TYPES_NEXT.SNOOZED_ENDS');
});
const contextMenuActions = {
close: () => {
isContextMenuOpen.value = false;
contextMenuPosition.value = { x: null, y: null };
emit('contextMenuClose');
},
open: e => {
e.preventDefault();
contextMenuPosition.value = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
isContextMenuOpen.value = true;
emit('contextMenuOpen');
},
handle: key => {
const actions = {
mark_as_read: () => emit('markNotificationAsRead', props.inboxItem),
mark_as_unread: () => emit('markNotificationAsUnRead', props.inboxItem),
delete: () => emit('deleteNotification', props.inboxItem),
};
actions[key]?.();
},
};
onBeforeMount(contextMenuActions.close);
</script>
<template>
<div
role="button"
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
@contextmenu="contextMenuActions.open($event)"
@click="emit('click')"
>
<div class="flex items-start gap-2">
<Avatar
:name="assigneeMeta.name"
:src="assigneeMeta.thumbnail"
:size="20"
rounded-full
class="mt-1"
/>
<p v-dompurify-html="formattedMessage" class="mb-0 line-clamp-2" />
</div>
<div class="flex items-center justify-between h-6 gap-2">
<div class="flex items-center flex-1 min-w-0 gap-1">
<div
v-if="snoozedUntilTime || hasLastSnoozed"
class="flex items-center w-full min-w-0 gap-2 ltr:pl-1 rtl:pr-1"
>
<Icon
:icon="
!hasLastSnoozed
? 'i-lucide-alarm-clock-plus'
: 'i-lucide-alarm-clock-off'
"
class="flex-shrink-0 size-4"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
/>
<span
class="text-xs font-medium truncate"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
>
{{ snoozedText }}
</span>
</div>
<div
v-else-if="notificationDetails.text"
class="flex items-center w-full min-w-0 gap-2 ltr:pl-1 rtl:pr-1"
>
<Icon
:icon="notificationDetails.icon"
:class="isUnread ? notificationDetails.color : 'text-n-slate-11'"
class="flex-shrink-0 size-4"
/>
<span
class="text-xs font-medium truncate"
:class="isUnread ? notificationDetails.color : 'text-n-slate-11'"
>
{{ notificationDetails.text }}
</span>
</div>
</div>
<div class="flex items-center flex-shrink-0 gap-2">
<SLACardLabel
v-show="hasSlaThreshold"
ref="slaCardLabel"
:conversation="primaryActor"
class="[&>span]:text-xs"
:class="
!isUnread && '[&>span]:text-n-slate-11 [&>div>svg]:fill-n-slate-11'
"
/>
<div v-if="hasSlaThreshold" class="w-px h-3 rounded-sm bg-n-slate-4" />
<CardPriorityIcon
v-if="primaryActor?.priority"
:priority="primaryActor?.priority"
class="[&>svg]:size-4"
/>
<div
v-if="inboxIcon"
v-tooltip.left="inbox?.name"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
</div>
<InboxContextMenu
v-if="isContextMenuOpen"
:context-menu-position="contextMenuPosition"
:menu-items="menuItems"
@close="contextMenuActions.close"
@select-action="contextMenuActions.handle"
/>
</div>
</template>
@@ -3,6 +3,7 @@ import { computed, ref, toRefs } from 'vue';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -127,6 +128,8 @@ const props = defineProps({
const contextMenuPosition = ref({});
const showContextMenu = ref(false);
const { t } = useI18n();
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
@@ -149,6 +152,11 @@ const variant = computed(() => {
if (props.contentAttributes?.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED;
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT;
}
const variants = {
[MESSAGE_TYPES.INCOMING]: MESSAGE_VARIANTS.USER,
[MESSAGE_TYPES.ACTIVITY]: MESSAGE_VARIANTS.ACTIVITY,
@@ -318,7 +326,6 @@ const contextMenuEnabledOptions = computed(() => {
delete: hasText || hasAttachments,
cannedResponse: isOutgoing && hasText,
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
toggleBg: variant.value === MESSAGE_VARIANTS.EMAIL,
};
});
@@ -354,19 +361,26 @@ function handleReplyTo() {
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, props);
}
function toggleBg() {
const key = `bg-light-toggle::${props.id}`;
const isToggled = LocalStorage.get(key);
if (isToggled) {
LocalStorage.remove(key);
} else {
LocalStorage.set(`bg-light-toggle::${props.id}`, true);
const avatarInfo = computed(() => {
if (!props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT) {
return {
name: t('CONVERSATION.BOT'),
src: '',
};
}
emitter.emit(BUS_EVENTS.TOGGLE_MESSAGE_BG, props.id);
closeContextMenu();
}
if (props.sender) {
return {
name: props.sender.name,
src: props.sender?.thumbnail,
};
}
return {
name: '',
src: '',
};
});
provideMessageContext({
...toRefs(props),
@@ -406,11 +420,7 @@ provideMessageContext({
v-if="!shouldGroupWithNext && shouldShowAvatar"
class="[grid-area:avatar] flex items-end"
>
<Avatar
:name="sender ? sender.name : ''"
:src="sender?.thumbnail"
:size="24"
/>
<Avatar v-bind="avatarInfo" :size="24" />
</div>
<div
class="[grid-area:bubble] flex"
@@ -440,7 +450,6 @@ provideMessageContext({
@open="openContextMenu"
@close="closeContextMenu"
@reply-to="handleReplyTo"
@toggle-bg="toggleBg"
/>
</div>
</div>
@@ -1,11 +1,15 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from './provider.js';
import { ORIENTATION } from './constants';
defineProps({
error: { type: String, required: true },
});
const { orientation } = useMessageContext();
const { t } = useI18n();
</script>
@@ -22,7 +26,11 @@ const { t } = useI18n();
/>
</div>
<div
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 right-0 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all"
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all"
:class="{
'left-0': orientation === ORIENTATION.LEFT,
'right-0': orientation === ORIENTATION.RIGHT,
}"
>
{{ error }}
</div>
@@ -74,7 +74,9 @@ const shouldGroupWithNext = (index, searchList) => {
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
currentMessageType === MESSAGE_TYPES.TEMPLATE;
if (!hasSameSender || !areBothTemplates) return false;
if (!hasSameSender || areBothTemplates) return false;
if (currentMessageType !== nextMessageType) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
@@ -58,14 +58,14 @@ const messageClass = computed(() => {
const scrollToMessage = () => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.message.id,
messageId: inReplyTo.value.id,
});
};
const previewMessage = computed(() => {
const replyToPreview = computed(() => {
if (!inReplyTo) return '';
const { content, attachments } = inReplyTo;
const { content, attachments } = inReplyTo.value;
if (content) return content;
if (attachments?.length) {
@@ -91,11 +91,11 @@ const previewMessage = computed(() => {
>
<div
v-if="inReplyTo"
class="bg-n-alpha-black1 rounded-lg p-2"
class="bg-n-alpha-black1 rounded-lg p-2 -mx-1 mb-2 cursor-pointer"
@click="scrollToMessage"
>
<span class="line-clamp-2">
{{ previewMessage }}
{{ replyToPreview }}
</span>
</div>
<slot />
@@ -1,9 +1,6 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { Letter } from 'vue-letter';
import { LocalStorage } from 'shared/helpers/localStorage';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
@@ -15,22 +12,16 @@ import EmailMeta from './EmailMeta.vue';
import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js';
const { id, content, contentAttributes, attachments, messageType } =
const { content, contentAttributes, attachments, messageType } =
useMessageContext();
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const useWhiteBackground = ref(false);
const contentContainer = useTemplateRef('contentContainer');
onMounted(() => {
isExpandable.value = contentContainer.value.scrollHeight > 400;
useWhiteBackground.value = LocalStorage.get(`bg-light-toggle::${id.value}`);
emitter.on(BUS_EVENTS.TOGGLE_MESSAGE_BG, () => {
useWhiteBackground.value = LocalStorage.get(`bg-light-toggle::${id.value}`);
});
});
const isOutgoing = computed(() => {
@@ -78,26 +69,25 @@ const textToShow = computed(() => {
{{ $t('EMAIL_HEADER.EXPAND') }}
</button>
</div>
<FormattedContent v-if="isOutgoing && content" :content="content" />
<div
v-else
:class="{
'dark:bg-white dark:p-4 rounded-lg text-black': useWhiteBackground,
}"
>
<FormattedContent
v-if="isOutgoing && content"
class="text-n-slate-12"
:content="content"
/>
<template v-else>
<Letter
v-if="showQuotedMessage"
class-name="prose prose-email !max-w-none"
class-name="prose prose-bubble !max-w-none"
:html="fullHTML"
:text="textToShow"
/>
<Letter
v-else
class-name="prose prose-email !max-w-none"
class-name="prose prose-bubble !max-w-none"
:html="unquotedHTML"
:text="textToShow"
/>
</div>
</template>
<button
v-if="hasQuotedMessage"
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
@@ -38,7 +38,7 @@ const onVideoLoadError = () => {
<template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
<div v-if="content" class="mb-2" v-html="formattedContent" />
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img
v-if="!hasImgStoryError"
class="rounded-lg max-w-80"
@@ -26,6 +26,6 @@ const formattedContent = computed(() => {
<template>
<span
v-dompurify-html="formattedContent"
class="[&_.link]:text-n-slate-11 [&_.link]:underline [&>p:last-child]:mb-0 [&>ul]:list-inside"
class="prose prose-bubble break-words"
/>
</template>
@@ -62,7 +62,7 @@ const textColorClass = computed(() => {
<a
v-tooltip="t('CONVERSATION.DOWNLOAD')"
class="flex-shrink-0 h-9 grid place-content-center cursor-pointer text-n-slate-11"
:href="url"
:href="attachment.dataUrl"
rel="noreferrer noopener nofollow"
target="_blank"
>
@@ -20,6 +20,7 @@ export const MESSAGE_VARIANTS = {
export const SENDER_TYPES = {
CONTACT: 'Contact',
USER: 'User',
AGENT_BOT: 'agent_bot',
};
export const ORIENTATION = {
@@ -84,6 +84,7 @@ const menuItems = computed(() => {
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
},
{
name: 'Conversation',
@@ -80,7 +80,7 @@ const toggleConversationLayout = () => {
<template>
<div
class="flex items-center justify-between gap-2 px-4 pb-0"
class="flex items-center justify-between gap-2 px-4 pb-0 mb-2"
:class="{
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-2.5': showV4View,
@@ -116,7 +116,7 @@ export default {
}
&.secondary {
@apply bg-slate-200 dark:bg-slate-300 text-slate-800 dark:text-slate-800;
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12;
a {
@apply text-slate-800 dark:text-slate-800;
}
@@ -10,9 +10,11 @@ import AIAssistanceModal from './AIAssistanceModal.vue';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
AIAssistanceModal,
AICTAModal,
AIAssistanceCTAButton,
@@ -128,13 +130,13 @@ export default {
v-if="shouldShowAIAssistCTAButton"
@open="openAIAssist"
/>
<woot-button
<NextButton
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
icon="i-ph-magic-wand"
slate
faded
sm
@click="openAIAssist"
/>
<woot-modal
@@ -1,27 +1,22 @@
<script>
export default {
emits: ['open'],
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
methods: {
onClick() {
this.$emit('open');
},
},
const emit = defineEmits(['open']);
const onClick = () => {
emit('open');
};
</script>
<template>
<div class="relative">
<woot-button
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
class-names="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
<NextButton
class="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
sm
@click="onClick"
>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST') }}
</woot-button>
/>
<div
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-woot-500 dark:bg-woot-500"
@@ -34,22 +29,26 @@ export default {
<style scoped>
@tailwind components;
@layer components {
/* Gradient animation */
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.cta-btn {
animation: gradient 5s ease infinite;
border: 0;
@apply text-n-slate-12 border-0 text-xs;
}
.cta-btn-light {
@@ -96,6 +95,7 @@ export default {
opacity: 0;
}
}
.radar-ping-animation {
animation: ping 1s ease infinite;
}
@@ -1,4 +1,5 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import router from '../../routes/index';
const props = defineProps({
backUrl: {
@@ -24,17 +25,21 @@ const goBack = () => {
};
const buttonStyleClass = props.compact
? 'text-sm text-slate-600 dark:text-slate-300'
: 'text-base text-woot-500 dark:text-woot-500';
? 'text-sm text-n-slate-11'
: 'text-base text-n-blue-text';
</script>
<template>
<button
class="flex items-center p-0 font-normal cursor-pointer"
class="flex items-center p-0 font-normal cursor-pointer gap-1"
:class="buttonStyleClass"
@click.capture="goBack"
>
<fluent-icon icon="chevron-left" class="-ml-1" />
<Icon
icon="i-lucide-chevron-left"
class="size-5 ltr:-ml-1 rtl:-mr-1"
:class="props.compact ? 'text-n-slate-11' : 'text-n-blue-text'"
/>
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>
</template>
@@ -2,8 +2,12 @@
import { mapGetters } from 'vuex';
import DyteAPI from 'dashboard/api/integrations/dyte';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
conversationId: {
type: Number,
@@ -43,16 +47,15 @@ export default {
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<woot-button
<NextButton
v-if="isVideoIntegrationEnabled"
v-tooltip.top-end="
$t('INTEGRATION_SETTINGS.DYTE.START_VIDEO_CALL_HELP_TEXT')
"
icon="video"
:is-loading="isLoading"
color-scheme="secondary"
variant="smooth"
size="small"
icon="i-ph-video-camera"
slate
faded
sm
@click="onClick"
/>
</template>
@@ -735,7 +735,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.ProseMirror-menubar {
min-height: var(--space-two) !important;
@apply -ml-2.5 pb-0 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
.ProseMirror-menu-active {
@apply bg-slate-75 dark:bg-slate-800;
@@ -787,10 +787,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-menubar-wrapper {
.ProseMirror-menubar {
@apply bg-yellow-100 dark:bg-yellow-800 text-slate-700 dark:text-slate-25;
}
> .ProseMirror {
@apply text-slate-800 dark:text-slate-25;
@@ -0,0 +1,64 @@
<script setup>
import { computed } from 'vue';
import { REPLY_EDITOR_MODES } from './constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
});
defineEmits(['toggleMode']);
const isPrivate = computed(() => props.mode === REPLY_EDITOR_MODES.NOTE);
</script>
<template>
<button
class="flex items-center h-8 p-1 transition-all border rounded-full duration-600 bg-n-alpha-2 dark:bg-n-alpha-2 hover:bg-n-alpha-1 dark:hover:brightness-105 group relative transition-all duration-300 ease-in-out"
:class="[
isPrivate
? 'border-n-amber-12/10 dark:border-n-amber-3/30 w-[128px]'
: 'border-n-weak dark:border-n-weak ',
]"
@click="$emit('toggleMode')"
>
<div
class="flex absolute items-center justify-center w-6 transition-all duration-200 rounded-full bg-n-alpha-black1 size-6 transition-all duration-300 ease-in-out"
:class="[
isPrivate
? 'ltr:translate-x-[94px] rtl:-translate-x-[94px]'
: 'translate-x-0',
]"
>
<Icon
:icon="
isPrivate
? 'i-material-symbols-lock'
: 'i-material-symbols-lock-open-rounded'
"
class="flex-shrink-0 size-3.5"
:class="[
isPrivate ? 'dark:text-n-amber-9 text-n-amber-11' : 'text-n-slate-10',
]"
/>
</div>
<span
class="flex items-center text-sm font-medium transition-all duration-200 w-fit whitespace-nowrap"
:class="[
isPrivate
? 'text-n-amber-12 ltr:mr-7 ltr:ml-1.5 rtl:ml-7 rtl:mr-1.5'
: 'text-n-slate-12 ltr:ml-7 ltr:mr-1.5 rtl:mr-7 rtl:ml-1.5',
]"
>
{{
isPrivate
? $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE')
: $t('CONVERSATION.REPLYBOX.REPLY')
}}
</span>
</button>
</template>
@@ -15,10 +15,11 @@ import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton },
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [inboxMixin],
props: {
mode: {
@@ -207,13 +208,13 @@ export default {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
case 'playing':
return 'microphone-pause';
return 'i-ph-pause';
case 'paused':
return 'microphone-play';
return 'i-ph-play';
case 'stopped':
return 'microphone-play';
return 'i-ph-play';
default:
return 'microphone-stop';
return 'i-ph-stop';
}
},
showMessageSignatureButton() {
@@ -253,15 +254,14 @@ export default {
</script>
<template>
<div class="bottom-box" :class="wrapClass">
<div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap">
<woot-button
<NextButton
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
:title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="emoji"
color-scheme="secondary"
variant="smooth"
size="small"
icon="i-ph-smiley-sticker"
slate
faded
sm
@click="toggleEmojiPicker"
/>
<FileUpload
@@ -279,62 +279,59 @@ export default {
}"
@input-file="onFileUpload"
>
<woot-button
<NextButton
v-if="showAttachButton"
class-names="button--upload"
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
icon="attach"
color-scheme="secondary"
variant="smooth"
size="small"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
icon="i-ph-paperclip"
slate
faded
sm
/>
</FileUpload>
<woot-button
<NextButton
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
variant="smooth"
size="small"
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
slate
faded
sm
@click="toggleAudioRecorder"
/>
<woot-button
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="quote"
color-scheme="secondary"
variant="smooth"
size="small"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<woot-button
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
color-scheme="secondary"
variant="smooth"
size="small"
slate
faded
sm
:label="recordingAudioDurationText"
@click="toggleAudioRecorderPlayPause"
>
<span>{{ recordingAudioDurationText }}</span>
</woot-button>
<woot-button
/>
<NextButton
v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip"
icon="signature"
color-scheme="secondary"
variant="smooth"
size="small"
:title="signatureToggleTooltip"
icon="i-ph-signature"
slate
faded
sm
@click="toggleMessageSignature"
/>
<woot-button
<NextButton
v-if="hasWhatsappTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="whatsapp"
color-scheme="secondary"
variant="smooth"
size="small"
:title="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp"
slate
faded
sm
@click="$emit('selectWhatsappTemplate')"
/>
<VideoCallButton
@@ -359,14 +356,13 @@ export default {
</h4>
</div>
</transition>
<woot-button
<NextButton
v-if="enableInsertArticleInReply"
v-tooltip.top-end="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
icon="document-text-link"
color-scheme="secondary"
variant="smooth"
size="small"
:title="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
icon="i-ph-article-ny-times"
slate
faded
sm
@click="toggleInsertArticle"
/>
</div>
@@ -384,14 +380,6 @@ export default {
</template>
<style lang="scss" scoped>
.bottom-box {
@apply flex justify-between py-3 px-4;
&.is-note-mode {
@apply bg-yellow-100 dark:bg-yellow-800;
}
}
.left-wrap {
@apply items-center flex gap-2;
}
@@ -1,9 +1,15 @@
<script>
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
export default {
name: 'ReplyTopPanel',
components: {
NextButton,
EditorModeToggle,
},
props: {
mode: {
type: String,
@@ -17,10 +23,6 @@ export default {
type: Number,
default: () => 0,
},
popoutReplyBox: {
type: Boolean,
default: false,
},
},
emits: ['setReplyMode', 'togglePopout'],
setup(props, { emit }) {
@@ -33,6 +35,13 @@ export default {
const handleNoteClick = () => {
setReplyMode(REPLY_EDITOR_MODES.NOTE);
};
const handleModeToggle = () => {
const newMode =
props.mode === REPLY_EDITOR_MODES.REPLY
? REPLY_EDITOR_MODES.NOTE
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
@@ -46,8 +55,10 @@ export default {
useKeyboardEvents(keyboardEvents);
return {
handleModeToggle,
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
};
},
computed: {
@@ -74,27 +85,12 @@ export default {
</script>
<template>
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
<div class="button-group">
<woot-button
variant="clear"
class="button--reply"
:class="replyButtonClass"
@click="handleReplyClick"
>
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
</woot-button>
<woot-button
class="button--note"
variant="clear"
color-scheme="warning"
:class="noteButtonClass"
@click="handleNoteClick"
>
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</woot-button>
</div>
<div class="flex justify-between h-[52px] gap-2 ltr:pl-3 rtl:pr-3">
<EditorModeToggle
:mode="mode"
class="mt-3"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass">
@@ -102,20 +98,10 @@ export default {
</span>
</div>
</div>
<woot-button
v-if="popoutReplyBox"
variant="clear"
icon="dismiss"
color-scheme="secondary"
class-names="popout-button"
@click="$emit('togglePopout')"
/>
<woot-button
v-else
variant="clear"
icon="resize-large"
color-scheme="secondary"
class-names="popout-button"
<NextButton
ghost
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
@@ -127,28 +113,35 @@ export default {
.button {
@apply text-sm font-medium py-2.5 px-4 m-0 relative z-10;
&.is-active {
@apply bg-white dark:bg-slate-900;
}
}
.button--reply {
@apply border-r rounded-none border-b-0 border-l-0 border-t-0 border-slate-50 dark:border-slate-700;
&:hover,
&:focus {
@apply border-r border-slate-50 dark:border-slate-700;
}
}
.button--note {
@apply border-l-0 rounded-none;
&.is-active {
@apply border-r border-b-0 bg-yellow-100 dark:bg-yellow-800 border-t-0 border-slate-50 dark:border-slate-700;
}
&:hover,
&:active {
@apply text-yellow-700 dark:text-yellow-700;
}
}
}
.button--note {
@apply text-yellow-600 dark:text-yellow-600 bg-transparent dark:bg-transparent;
}
@@ -140,7 +140,7 @@ export default {
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:ml-0 rtl:mr-0 rtl:ml-4"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
:src="currentContact.thumbnail"
@@ -574,7 +574,10 @@ export default {
</ul>
<div
class="conversation-footer"
:class="{ 'modal-mask': isPopOutReplyBox }"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-background': showNextBubbles && !isPopOutReplyBox,
}"
>
<div
v-if="isAnyoneTyping"
@@ -623,7 +626,11 @@ export default {
}
.reply-box {
@apply border border-solid border-slate-75 dark:border-slate-600 max-w-[75rem] w-[70%];
@apply border border-n-weak max-w-[75rem] w-[70%];
&.is-private {
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
}
}
.reply-box .reply-box__top {
@@ -1071,17 +1071,17 @@ export default {
</script>
<template>
<Banner
v-if="showSelfAssignBanner"
action-button-variant="clear"
color-scheme="secondary"
class="banner--self-assign mx-2 mb-2 rounded-lg"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<div class="reply-box" :class="replyBoxClass">
<Banner
v-if="showSelfAssignBanner"
action-button-variant="clear"
color-scheme="secondary"
class="banner--self-assign"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<ReplyTopPanel
:mode="replyType"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
@@ -1242,28 +1242,12 @@ export default {
}
.reply-box {
transition:
box-shadow 0.35s cubic-bezier(0.37, 0, 0.63, 1),
height 2s cubic-bezier(0.37, 0, 0.63, 1);
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
@apply relative border-t border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-900;
&.is-focused {
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
&.is-private {
@apply bg-yellow-100 dark:bg-yellow-800;
.reply-box__top {
@apply bg-yellow-100 dark:bg-yellow-800;
> input {
@apply bg-yellow-100 dark:bg-yellow-800;
}
}
@apply bg-n-solid-amber dark:border-n-amber-3/10 border-n-amber-12/5;
}
}
@@ -1272,7 +1256,7 @@ export default {
}
.reply-box__top {
@apply relative py-0 px-4 -mt-px border-t border-solid border-slate-50 dark:border-slate-700;
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
@@ -89,11 +89,11 @@ export default {
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.TO') }}
</label>
<div class="rounded-none flex-1 min-w-0 m-0 whitespace-nowrap">
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.toEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
:class="{ error: v$.toEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
@@ -106,10 +106,10 @@ export default {
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.LABEL') }}
</label>
<div class="rounded-none flex-1 min-w-0 m-0 whitespace-nowrap">
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.ccEmailsVal.$model"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
type="text"
:class="{ error: v$.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@@ -134,11 +134,11 @@ export default {
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.LABEL') }}
</label>
<div class="rounded-none flex-1 min-w-0 m-0 whitespace-nowrap">
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.bccEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
:class="{ error: v$.bccEmailsVal.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
@@ -66,3 +66,32 @@ export const snoozedReopenTime = snoozedUntil => {
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const unitMap = {
minutes: 'm',
minute: 'm',
hours: 'h',
hour: 'h',
days: 'd',
day: 'd',
months: 'mo',
month: 'mo',
years: 'y',
year: 'y',
};
const shortenTime = snoozedUntil
.replace(/^in\s+/i, '')
.replace(
/\s(minute|hour|day|month|year)s?\b/gi,
(match, unit) => unitMap[unit.toLowerCase()] || match
);
return shortenTime;
};
@@ -5,6 +5,8 @@ import {
findStartOfNextMonth,
findNextDay,
setHoursToNine,
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
} from '../snoozeHelpers';
describe('#Snooze Helpers', () => {
@@ -107,4 +109,45 @@ describe('#Snooze Helpers', () => {
expect(findNextDay(today)).toEqual(nextDay);
});
});
describe('snoozedReopenTimeToTimestamp', () => {
it('should return timestamp if snoozedUntil is not nil', () => {
expect(snoozedReopenTimeToTimestamp('2023-06-07T09:00:00.000Z')).toEqual(
1686128400
);
});
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTimeToTimestamp(null)).toEqual(null);
});
});
describe('shortenSnoozeTime', () => {
it('should return shortened time if snoozedUntil is not nil and day is passed', () => {
expect(shortenSnoozeTime('1 day')).toEqual('1d');
});
it('should return shortened time if snoozedUntil is not nil and month is passed', () => {
expect(shortenSnoozeTime('1 month')).toEqual('1mo');
});
it('should return shortened time if snoozedUntil is not nil and year is passed', () => {
expect(shortenSnoozeTime('1 year')).toEqual('1y');
});
it('should return shortened time if snoozedUntil is not nil and hour is passed', () => {
expect(shortenSnoozeTime('1 hour')).toEqual('1h');
});
it('should return shortened time if snoozedUntil is not nil and minutes is passed', () => {
expect(shortenSnoozeTime('1 minutes')).toEqual('1m');
});
it('should return shortened time if snoozedUntil is not nil and in is passed', () => {
expect(shortenSnoozeTime('in 1 hour')).toEqual('1h');
});
it('should return nil if snoozedUntil is nil', () => {
expect(shortenSnoozeTime(null)).toEqual(null);
});
});
});
@@ -218,7 +218,6 @@
"DELETE": "Delete",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate",
"TOGGLE_BG": "Toggle background color",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
@@ -1,7 +1,7 @@
{
"INBOX": {
"LIST": {
"TITLE": "Inbox",
"TITLE": "My Inbox",
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"404": "There are no active notifications in this group.",
@@ -27,6 +27,19 @@
"SLA_MISSED_NEXT_RESPONSE": "SLA target next response missed for conversation",
"SLA_MISSED_RESOLUTION": "SLA target resolution missed for conversation"
},
"TYPES_NEXT": {
"CONVERSATION_MENTION": "Mentioned",
"CONVERSATION_ASSIGNMENT": "Assigned to you",
"CONVERSATION_CREATION": "New Conversation",
"SLA_MISSED_FIRST_RESPONSE": "SLA breach",
"SLA_MISSED_NEXT_RESPONSE": "SLA breach",
"SLA_MISSED_RESOLUTION": "SLA breach",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "New message",
"SNOOZED_UNTIL": "Snoozed for {time}",
"SNOOZED_ENDS": "Snooze ended"
},
"NO_CONTENT": "No content available",
"MENU_ITEM": {
"MARK_AS_READ": "Mark as read",
"MARK_AS_UNREAD": "Mark as unread",
@@ -254,7 +254,7 @@
"SWITCH": "Switch",
"INBOX_VIEW": "Inbox View",
"CONVERSATIONS": "Conversations",
"INBOX": "Inbox",
"INBOX": "My Inbox",
"ALL_CONVERSATIONS": "All Conversations",
"MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating",
@@ -44,7 +44,7 @@ export default {
default: false,
},
},
emits: ['open', 'close', 'replyTo', 'toggleBg'],
emits: ['open', 'close', 'replyTo'],
setup() {
const { getPlainText } = useMessageFormatter();
return {
@@ -116,9 +116,6 @@ export default {
handleClose(e) {
this.$emit('close', e);
},
toggleBackground(e) {
this.$emit('toggleBg', e);
},
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
this.$store.dispatch('translateMessage', {
@@ -235,15 +232,6 @@ export default {
variant="icon"
@click.stop="handleTranslate"
/>
<MenuItem
v-if="enabledOptions['toggleBg']"
:option="{
icon: 'arrow-rotate-clockwise',
label: $t('CONVERSATION.CONTEXT_MENU.TOGGLE_BG'),
}"
variant="icon"
@click.stop="toggleBackground"
/>
<hr />
<MenuItem
:option="{
@@ -23,16 +23,16 @@ export default {
<template>
<div
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full hidden md:flex items-center"
class="items-center justify-center hidden w-full h-full text-center bg-n-background lg:flex"
>
<span v-if="uiFlags.isFetching" class="spinner my-4" />
<span v-if="uiFlags.isFetching" class="my-4 spinner" />
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
<span class="text-sm font-medium text-slate-500 dark:text-slate-300">
{{ emptyMessage }}
</span>
</div>
@@ -4,7 +4,7 @@ import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import wootConstants from 'dashboard/constants/globals';
import InboxCard from './components/InboxCard.vue';
import InboxCard from 'dashboard/components-next/Inbox/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
@@ -43,6 +43,8 @@ export default {
meta: 'notifications/getMeta',
uiFlags: 'notifications/getUIFlags',
notification: 'notifications/getFilteredNotifications',
notificationV4: 'notifications/getFilteredNotificationsV4',
inboxById: 'inboxes/getInboxById',
}),
currentNotificationId() {
return Number(this.$route.params.notification_id);
@@ -58,6 +60,9 @@ export default {
notifications() {
return this.notification(this.inboxFilters);
},
notificationsV4() {
return this.notificationV4(this.inboxFilters);
},
showEndOfList() {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
@@ -77,6 +82,9 @@ export default {
this.fetchNotifications();
},
methods: {
stateInbox(inboxId) {
return this.inboxById(inboxId);
},
fetchNotifications() {
this.page = 1;
this.$store.dispatch('notifications/clear');
@@ -164,15 +172,42 @@ export default {
this.inboxFilters
);
},
openConversation(notification) {
const {
id,
primaryActorId,
primaryActorType,
primaryActor: { inboxId },
notificationType,
} = notification;
if (this.$route.params.notification_id !== id) {
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, notification_id: id },
});
}
},
},
};
</script>
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<section class="flex w-full h-full bg-n-solid-1">
<div
class="flex flex-col h-full w-full md:min-w-[360px] md:max-w-[360px] ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="!currentNotificationId ? 'flex' : 'hidden md:flex'"
class="flex flex-col h-full w-full lg:min-w-[400px] lg:max-w-[400px] ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="!currentNotificationId ? 'flex' : 'hidden xl:flex'"
>
<InboxListHeader
:is-context-menu-open="isInboxContextMenuOpen"
@@ -181,18 +216,25 @@ export default {
/>
<div
ref="notificationList"
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
class="flex flex-col gap-px w-full h-[calc(100%-56px)] pb-3 overflow-x-hidden px-3 overflow-y-auto divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*.active]:!border-y-transparent [&>*:hover+*]:!border-t-transparent [&>*.active+*]:!border-t-transparent"
>
<InboxCard
v-for="notificationItem in notifications"
v-for="notificationItem in notificationsV4"
:key="notificationItem.id"
:active="currentNotificationId === notificationItem.id"
:notification-item="notificationItem"
:inbox-item="notificationItem"
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="
currentNotificationId === notificationItem.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 click-animation rounded-xl active'
: ''
"
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-un-read="markNotificationAsUnRead"
@delete-notification="deleteNotification"
@context-menu-open="isInboxContextMenuOpen = true"
@context-menu-close="isInboxContextMenuOpen = false"
@click="openConversation(notificationItem)"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="mt-4 mb-4 spinner" />
@@ -214,3 +256,23 @@ export default {
<CmdBarConversationSnooze />
</section>
</template>
<style scoped>
.click-animation {
animation: click-animation 0.2s ease-in-out;
}
@keyframes click-animation {
0% {
transform: scale(1);
}
50% {
transform: scale(0.99);
}
100% {
transform: scale(1);
}
}
</style>
@@ -175,7 +175,7 @@ export default {
</script>
<template>
<div class="h-full w-full md:w-[calc(100%-360px)]">
<div class="h-full w-full xl:w-[calc(100%-400px)]">
<div v-if="showEmptyState" class="flex w-full h-full">
<InboxEmptyState
:empty-state-message="$t('INBOX.LIST.NO_MESSAGES_AVAILABLE')"
@@ -198,7 +198,7 @@ export default {
</div>
<ConversationBox
v-else
class="h-[calc(100%-56px)]"
class="h-[calc(100%-56px)] [&.conversation-details-wrap]:!border-0"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
@@ -1,245 +0,0 @@
<script>
import PriorityIcon from './PriorityIcon.vue';
import StatusIcon from './StatusIcon.vue';
import InboxNameAndId from './InboxNameAndId.vue';
import InboxContextMenu from './InboxContextMenu.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
export default {
components: {
PriorityIcon,
InboxContextMenu,
StatusIcon,
InboxNameAndId,
Thumbnail,
},
props: {
notificationItem: {
type: Object,
default: () => {},
},
active: {
type: Boolean,
default: false,
},
},
emits: [
'contextMenuClose',
'contextMenuOpen',
'markNotificationAsRead',
'markNotificationAsUnRead',
'deleteNotification',
],
data() {
return {
isContextMenuOpen: false,
contextMenuPosition: { x: null, y: null },
};
},
computed: {
primaryActor() {
return this.notificationItem?.primary_actor;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](
this.primaryActor.inbox_id
);
},
isUnread() {
return !this.notificationItem?.read_at;
},
meta() {
return this.primaryActor?.meta;
},
assigneeMeta() {
return this.meta?.assignee;
},
pushTitle() {
return this.$t(
`INBOX.TYPES.${this.notificationItem.notification_type.toUpperCase()}`
);
},
lastActivityAt() {
const time = dynamicTime(this.notificationItem?.last_activity_at);
return shortTimestamp(time, true);
},
menuItems() {
const items = [
{
key: 'delete',
label: this.$t('INBOX.MENU_ITEM.DELETE'),
},
];
if (!this.isUnread) {
items.push({
key: 'mark_as_unread',
label: this.$t('INBOX.MENU_ITEM.MARK_AS_UNREAD'),
});
} else {
items.push({
key: 'mark_as_read',
label: this.$t('INBOX.MENU_ITEM.MARK_AS_READ'),
});
}
return items;
},
snoozedUntilTime() {
const { snoozed_until: snoozedUntil } = this.notificationItem;
return snoozedUntil;
},
snoozedDisplayText() {
if (this.snoozedUntilTime) {
return `${this.$t('INBOX.LIST.SNOOZED_UNTIL')} ${snoozedReopenTime(
this.snoozedUntilTime
)}`;
}
return '';
},
},
unmounted() {
this.closeContextMenu();
},
methods: {
openConversation(notification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { inbox_id: inboxId },
notification_type: notificationType,
} = notification;
if (this.$route.params.notification_id !== id) {
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, notification_id: id },
});
}
},
closeContextMenu() {
this.isContextMenuOpen = false;
this.contextMenuPosition = { x: null, y: null };
this.$emit('contextMenuClose');
},
openContextMenu(e) {
this.closeContextMenu();
e.preventDefault();
this.contextMenuPosition = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
this.isContextMenuOpen = true;
this.$emit('contextMenuOpen');
},
handleAction(key) {
switch (key) {
case 'mark_as_read':
this.$emit('markNotificationAsRead', this.notificationItem);
break;
case 'mark_as_unread':
this.$emit('markNotificationAsUnRead', this.notificationItem);
break;
case 'delete':
this.$emit('deleteNotification', this.notificationItem);
break;
default:
}
},
},
};
</script>
<template>
<div
role="button"
class="flex flex-col ltr:pl-5 rtl:pl-3 rtl:pr-5 ltr:pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
:class="
active
? 'bg-slate-25 dark:bg-slate-800 click-animation'
: 'bg-white dark:bg-slate-900'
"
@contextmenu="openContextMenu($event)"
@click="openConversation(notificationItem)"
>
<div class="relative flex items-center justify-between w-full">
<div
v-if="isUnread"
class="absolute ltr:-left-3.5 rtl:-right-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" :conversation-id="primaryActor.id" />
<div class="flex gap-2">
<PriorityIcon :priority="primaryActor.priority" />
<StatusIcon :status="primaryActor.status" />
</div>
</div>
<div class="flex flex-row items-center justify-between w-full gap-2">
<Thumbnail
v-if="assigneeMeta"
:src="assigneeMeta.thumbnail"
:username="assigneeMeta.name"
size="16px"
/>
<span
class="flex-1 overflow-hidden text-sm text-slate-800 dark:text-slate-50 text-ellipsis whitespace-nowrap"
:class="isUnread ? 'font-medium' : 'font-normal'"
>
{{ pushTitle }}
</span>
<span
class="text-xs font-medium text-slate-600 dark:text-slate-300 whitespace-nowrap"
>
{{ lastActivityAt }}
</span>
</div>
<div v-if="snoozedUntilTime" class="flex items-center">
<span class="text-xs font-medium text-woot-500 dark:text-woot-500">
{{ snoozedDisplayText }}
</span>
</div>
<InboxContextMenu
v-if="isContextMenuOpen"
:context-menu-position="contextMenuPosition"
:menu-items="menuItems"
@close="closeContextMenu"
@select-action="handleAction"
/>
</div>
</template>
<style scoped>
.click-animation {
animation: click-animation 0.3s ease-in-out;
}
@keyframes click-animation {
0% {
transform: scale(1);
}
50% {
transform: scale(0.99);
}
100% {
transform: scale(1);
}
}
</style>
@@ -37,7 +37,7 @@ export default {
@close="handleClose"
>
<div
class="bg-white dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl"
class="bg-n-alpha-3 backdrop-blur-[100px] w-40 py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl"
>
<MenuItem
v-for="item in menuItems"
@@ -2,7 +2,12 @@
import wootConstants from 'dashboard/constants/globals';
import { useUISettings } from 'dashboard/composables/useUISettings';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['filter'],
setup() {
@@ -110,77 +115,63 @@ export default {
<template>
<div
class="flex flex-col bg-white z-50 dark:bg-slate-900 w-[170px] border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl divide-y divide-slate-100 dark:divide-slate-700/50"
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container shadow-lg z-50 w-[170px] rounded-xl divide-y divide-n-weak dark:divide-n-strong"
>
<div class="flex items-center justify-between p-3 rounded-t-lg h-11">
<div class="flex gap-1.5">
<fluent-icon
icon="arrow-sort"
type="outline"
size="16"
class="text-slate-700 dark:text-slate-100"
/>
<span class="text-xs font-medium text-slate-800 dark:text-slate-100">
<span class="i-lucide-arrow-down-up size-3.5 text-n-slate-12" />
<span class="text-xs font-medium text-n-slate-12">
{{ $t('INBOX.DISPLAY_MENU.SORT') }}
</span>
</div>
<div class="relative">
<div
role="button"
class="border h-5 flex gap-1 rounded-md items-center pr-1.5 pl-1 py-0.5 w-[70px] justify-between border-slate-100 dark:border-slate-700/50"
<div v-on-clickaway="() => (showSortMenu = false)" class="relative">
<NextButton
:label="activeSortOption"
icon="i-lucide-chevron-down"
slate
trailing-icon
xs
outline
class="w-20"
@click="openSortMenu"
>
<span class="text-xs font-medium text-slate-600 dark:text-slate-300">
{{ activeSortOption }}
</span>
<fluent-icon
icon="chevron-down"
size="12"
class="text-slate-600 dark:text-slate-200"
/>
</div>
/>
<div
v-if="showSortMenu"
class="absolute flex flex-col gap-0.5 bg-white z-60 dark:bg-slate-800 rounded-md p-0.5 top-0 w-[70px] border border-slate-100 dark:border-slate-700/50"
class="absolute flex flex-col gap-0.5 bg-n-alpha-3 backdrop-blur-[100px] z-60 rounded-lg p-0.5 w-20 top-px outline outline-1 outline-n-container dark:outline-n-strong"
>
<div
v-for="option in sortOptions"
:key="option.key"
role="button"
class="flex rounded-[4px] h-5 w-full items-center justify-between p-0.5 gap-1"
class="flex rounded-md h-5 w-full items-center justify-between px-1.5 py-0.5 gap-1"
:class="{
'bg-woot-50 dark:bg-woot-700/50': activeSort === option.key,
'bg-n-brand/10 dark:bg-n-brand/10': activeSort === option.key,
}"
@click.stop="onSortOptionClick(option)"
>
<span
class="text-xs font-medium hover:text-woot-600 dark:hover:text-woot-600"
class="text-xs font-medium hover:text-n-brand dark:hover:text-n-brand"
:class="{
'text-woot-600 dark:text-woot-600': activeSort === option.key,
'text-slate-600 dark:text-slate-300': activeSort !== option.key,
'text-n-blue-text dark:text-n-blue-text':
activeSort === option.key,
'text-n-slate-11': activeSort !== option.key,
}"
>
{{ option.name }}
</span>
<fluent-icon
<span
v-if="activeSort === option.key"
icon="checkmark"
size="14"
class="text-woot-600 dark:text-woot-600"
class="i-lucide-check size-2.5 text-n-blue-text"
/>
</div>
</div>
</div>
</div>
<div>
<span
class="px-3 py-4 text-xs font-medium text-slate-400 dark:text-slate-400"
>
<span class="px-3 py-4 text-xs font-medium text-n-slate-11">
{{ $t('INBOX.DISPLAY_MENU.DISPLAY') }}
</span>
<div
class="flex flex-col divide-y divide-slate-100 dark:divide-slate-700/50"
>
<div class="flex flex-col divide-y divide-n-weak dark:divide-n-strong">
<div
v-for="option in displayOptions"
:key="option.key"
@@ -191,7 +182,7 @@ export default {
type="checkbox"
:name="option.key"
:checked="option.selected"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-n-brand dark:checked:bg-n-brand after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
@change="updateDisplayOption(option)"
/>
<label
@@ -9,10 +9,14 @@ import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import PaginationButton from './PaginationButton.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import { emitter } from 'shared/helpers/mitt';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
PaginationButton,
NextButton,
BackButton,
CustomSnoozeModal,
},
props: {
@@ -107,45 +111,39 @@ export default {
<div
class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-slate-50 dark:border-slate-800/50"
>
<woot-button
variant="clear link"
class="flex md:hidden !pt-1 !pb-1 rounded-md ltr:pr-1 rtl:pl-1 !no-underline"
size="medium"
color-scheme="primary"
icon="chevron-left"
@click="onClickGoToInboxList"
>
{{ $t('INBOX.ACTION_HEADER.BACK') }}
</woot-button>
<PaginationButton
v-if="totalLength > 1"
:total-length="totalLength"
:current-index="currentIndex + 1"
@next="onClickNext"
@prev="onClickPrev"
/>
<div v-else />
<div class="flex items-center gap-4">
<BackButton
compact
:button-label="$t('INBOX.ACTION_HEADER.BACK')"
class="xl:hidden flex"
/>
<PaginationButton
v-if="totalLength > 1"
:total-length="totalLength"
:current-index="currentIndex + 1"
@next="onClickNext"
@prev="onClickPrev"
/>
</div>
<div class="flex items-center gap-2">
<woot-button
variant="hollow"
size="small"
color-scheme="secondary"
icon="snooze"
class="[&>span]:hidden md:[&>span]:inline-flex"
<NextButton
:label="$t('INBOX.ACTION_HEADER.SNOOZE')"
icon="i-lucide-bell-minus"
slate
xs
outline
class="[&>.truncate]:hidden md:[&>.truncate]:block"
@click="openSnoozeNotificationModal"
>
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
</woot-button>
<woot-button
icon="delete"
size="small"
color-scheme="secondary"
variant="hollow"
class="[&>span]:hidden md:[&>span]:inline-flex"
/>
<NextButton
:label="$t('INBOX.ACTION_HEADER.DELETE')"
icon="i-lucide-trash-2"
slate
xs
outline
class="[&>.truncate]:hidden md:[&>.truncate]:block"
@click="deleteNotification"
>
{{ $t('INBOX.ACTION_HEADER.DELETE') }}
</woot-button>
/>
</div>
<woot-modal
v-model:show="showCustomSnoozeModal"
@@ -1,11 +1,14 @@
<script>
import { useAlert, useTrack } from 'dashboard/composables';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import InboxOptionMenu from './InboxOptionMenu.vue';
import InboxDisplayMenu from './InboxDisplayMenu.vue';
export default {
components: {
NextButton,
InboxOptionMenu,
InboxDisplayMenu,
},
@@ -77,56 +80,44 @@ export default {
<template>
<div
class="flex items-center justify-between w-full py-2 border-b ltr:pl-4 rtl:pl-2 rtl:pr-4 ltr:pr-2 h-14 border-slate-50 dark:border-slate-800/50"
class="flex items-center justify-between w-full gap-1 pt-4 pb-2 h-14 ltr:pl-6 rtl:pl-3 rtl:pr-6 ltr:pr-3"
>
<div class="flex items-center gap-1.5">
<h1 class="text-xl font-medium text-slate-900 dark:text-slate-25">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<h1
class="min-w-0 text-xl font-medium truncate text-slate-900 dark:text-slate-25"
>
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
<div
role="button"
class="flex items-center gap-1 px-2 py-1 border rounded-md border-slate-100 dark:border-slate-700/50"
<NextButton
:label="$t('INBOX.LIST.DISPLAY_DROPDOWN')"
icon="i-lucide-chevron-down"
trailing-icon
slate
xs
faded
@click="openInboxDisplayMenu"
>
<span
class="text-xs font-medium text-center text-slate-600 dark:text-slate-200"
>
{{ $t('INBOX.LIST.DISPLAY_DROPDOWN') }}
</span>
<fluent-icon
icon="chevron-down"
size="12"
class="text-slate-600 dark:text-slate-200"
/>
</div>
/>
<InboxDisplayMenu
v-if="showInboxDisplayMenu"
v-on-clickaway="openInboxDisplayMenu"
class="absolute top-9 ltr:left-0 rtl:right-0"
class="absolute mt-1 top-full ltr:left-0 rtl:right-0"
@filter="onFilterChange"
/>
</div>
</div>
<div class="relative flex items-center gap-1">
<!-- <woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="filter"
@click="openInboxFilter"
/> -->
<woot-button
variant="clear"
size="small"
color-scheme="secondary"
icon="mail-inbox"
<NextButton
icon="i-lucide-sliders-vertical"
slate
xs
faded
@click="openInboxOptionsMenu"
/>
<InboxOptionMenu
v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu"
class="absolute top-9 ltr:right-0 ltr:md:right-[unset] rtl:left-0 rtl:md:left-[unset]"
class="absolute top-full mt-1 ltr:right-0 ltr:lg:right-[unset] rtl:left-0 rtl:md:left-[unset]"
@option-click="onInboxOptionMenuClick"
/>
</div>
@@ -1,45 +0,0 @@
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
conversationId: {
type: Number,
default: 0,
},
},
computed: {
inboxIcon() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
</script>
<template>
<div
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-700/50 divide-x divide-slate-100 dark:divide-slate-700/50 bg-none"
>
<div v-if="inbox" class="flex items-center gap-0.5 py-0.5 px-1.5">
<fluent-icon
class="text-slate-600 dark:text-slate-300"
:icon="inboxIcon"
size="14"
/>
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ inbox.name }}
</span>
</div>
<div class="flex items-center py-0.5 px-1.5">
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ conversationId }}
</span>
</div>
</div>
</template>
@@ -33,7 +33,7 @@ export default {
<template>
<div
class="z-50 flex flex-col w-40 gap-1 py-1 bg-white border divide-y shadow-md dark:bg-slate-900 border-slate-100 dark:border-slate-700/50 rounded-xl divide-slate-100 dark:divide-slate-700/50"
class="z-50 flex flex-col w-40 gap-1 bg-n-alpha-3 backdrop-blur-[100px] divide-y py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl divide-n-weak dark:divide-n-strong"
>
<div class="flex flex-col">
<MenuItem
@@ -10,7 +10,7 @@ defineProps({
<template>
<div
role="button"
class="flex items-center w-full h-8 px-2 py-1 overflow-hidden text-xs font-medium rounded-md cursor-pointer text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis hover:text-woot-600 dark:hover:text-woot-500"
class="flex items-center w-full h-8 px-2 py-1 overflow-hidden text-xs font-medium rounded-md cursor-pointer text-n-slate-12 whitespace-nowrap text-ellipsis hover:text-n-blue-text"
>
{{ label }}
</div>
@@ -1,5 +1,10 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
totalLength: {
type: Number,
@@ -37,34 +42,34 @@ export default {
<template>
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
<woot-button
size="tiny"
variant="hollow"
color-scheme="secondary"
icon="chevron-up"
<NextButton
icon="i-lucide-chevron-up"
xs
slate
outline
:disabled="isUpDisabled"
@click="handleUpClick"
/>
<woot-button
size="tiny"
variant="hollow"
color-scheme="secondary"
icon="chevron-down"
<NextButton
icon="i-lucide-chevron-down"
xs
slate
outline
:disabled="isDownDisabled"
@click="handleDownClick"
/>
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-sm font-medium text-gray-600 tabular-nums">
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
{{ totalLength <= 1 ? '1' : currentIndex }}
</span>
<span
v-if="totalLength > 1"
class="text-sm text-slate-400 relative -top-px"
class="text-sm text-n-slate-9 relative -top-px"
>
/
</span>
<span v-if="totalLength > 1" class="text-sm text-slate-400 tabular-nums">
<span v-if="totalLength > 1" class="text-sm text-n-slate-9 tabular-nums">
{{ totalLength }}
</span>
</div>
@@ -1,80 +0,0 @@
<script>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
export default {
props: {
priority: {
type: String,
default: '',
},
},
data() {
return {
CONVERSATION_PRIORITY,
};
},
};
</script>
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
class="h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
<rect x="16" y="4" width="4" height="16" rx="1" fill="#FFC291" />
</svg>
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
class="h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
<rect x="10" y="8" width="4" height="12" rx="1" fill="#DDDDDD" />
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
class="h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
class="h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="12" width="4" height="8" rx="1" fill="#E5484D" />
<rect x="10" y="8" width="4" height="12" rx="1" fill="#E5484D" />
<rect x="16" y="4" width="4" height="16" rx="1" fill="#E5484D" />
</svg>
</div>
</template>
@@ -1,86 +0,0 @@
<script>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
export default {
props: {
status: {
type: String,
default: '',
},
},
data() {
return {
CONVERSATION_STATUS,
};
},
};
</script>
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Pending -->
<svg
v-if="status === CONVERSATION_STATUS.PENDING"
class="h-3.5 w-3.5"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.1 0.0449978V1.8558C4.5486 2.2986 1.8 5.328 1.8 9C1.8 12.9762 5.0238 16.2 9 16.2C10.6641 16.2 12.195 15.6357 13.4154 14.688L14.6961 15.9687C13.1445 17.2377 11.16 18 9 18C4.0293 18 0 13.9707 0 9C0 4.3335 3.5523 0.495898 8.1 0.0449978ZM17.955 9.9C17.775 11.7099 17.0604 13.3623 15.9687 14.6952L14.688 13.4154C15.462 12.4191 15.9804 11.2149 16.1442 9.9H17.9559H17.955ZM9.9018 0.0449978C14.1534 0.467098 17.5338 3.8484 17.9568 8.1H16.1451C15.7392 4.8438 13.158 2.2626 9.9018 1.8558V0.0440979V0.0449978Z"
class="fill-[#B9BBC6]"
/>
</svg>
<!-- Open -->
<svg
v-if="status === CONVERSATION_STATUS.OPEN"
class="h-3.5 w-3.5"
width="19"
height="19"
viewBox="0 0 19 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.375 18.875C4.19733 18.875 0 14.6776 0 9.5C0 4.32233 4.19733 0.125 9.375 0.125C14.5526 0.125 18.75 4.32233 18.75 9.5C18.75 14.6776 14.5526 18.875 9.375 18.875ZM9.375 17C13.5172 17 16.875 13.6422 16.875 9.5C16.875 5.35786 13.5172 2 9.375 2C5.23286 2 1.875 5.35786 1.875 9.5C1.875 13.6422 5.23286 17 9.375 17Z"
class="fill-[#ED8A5C]"
/>
</svg>
<!-- Snoozed -->
<svg
v-if="status === CONVERSATION_STATUS.SNOOZED"
class="h-3.5 w-3.5"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 18C4.0293 18 0 13.9707 0 9C0 4.0293 4.0293 0 9 0C13.9707 0 18 4.0293 18 9C18 13.9707 13.9707 18 9 18ZM2.9961 12.9825C3.58766 13.8676 4.36812 14.6105 5.28129 15.1577C6.19446 15.7049 7.21761 16.0428 8.27707 16.147C9.33652 16.2513 10.4059 16.1193 11.4082 15.7606C12.4105 15.4019 13.3208 14.8254 14.0736 14.0726C14.8263 13.3198 15.4027 12.4094 15.7613 11.4071C16.12 10.4047 16.2518 9.33532 16.1475 8.27588C16.0431 7.21644 15.7052 6.19332 15.1579 5.2802C14.6106 4.36707 13.8676 3.58668 12.9825 2.9952C13.3706 4.3796 13.383 5.84237 13.0186 7.23318C12.6542 8.62399 11.926 9.89269 10.9089 10.9089C9.89277 11.9258 8.62423 12.6539 7.23359 13.0183C5.84296 13.3828 4.38037 13.3704 2.9961 12.9825Z"
class="fill-[#0B68CB]"
/>
</svg>
<!-- Resolved -->
<svg
v-if="status === CONVERSATION_STATUS.RESOLVED"
class="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="3 3 17.92 17.92"
>
<path
d="M11.96 20.92C7.01152 20.92 3 16.9084 3 11.96C3 7.01152 7.01152 3 11.96 3C16.9084 3 20.92 7.01152 20.92 11.96C20.92 16.9084 16.9084 20.92 11.96 20.92ZM11.96 19.128C15.9188 19.128 19.128 15.9188 19.128 11.96C19.128 8.00122 15.9188 4.792 11.96 4.792C8.00122 4.792 4.792 8.00122 4.792 11.96C4.792 15.9188 8.00122 19.128 11.96 19.128Z"
class="fill-[#5BB98C]"
/>
<path
d="M11.9599 17.9333C15.2589 17.9333 17.9332 15.2589 17.9332 11.96C17.9332 8.66098 15.2589 5.98663 11.9599 5.98663C8.66092 5.98663 5.98657 8.66098 5.98657 11.96C5.98657 15.2589 8.66092 17.9333 11.9599 17.9333Z"
class="fill-[#5BB98C]"
/>
</svg>
</div>
</template>
@@ -0,0 +1,16 @@
export const NOTIFICATION_TYPES_MAPPING = {
CONVERSATION_MENTION: ['i-lucide-at-sign', 'text-n-blue-text'],
CONVERSATION_ASSIGNMENT: ['i-lucide-chevrons-right', 'text-n-blue-text'],
CONVERSATION_CREATION: ['i-lucide-mail-plus', 'text-n-blue-text'],
PARTICIPATING_CONVERSATION_NEW_MESSAGE: [
'i-lucide-message-square-plus',
'text-n-blue-text',
],
ASSIGNED_CONVERSATION_NEW_MESSAGE: [
'i-lucide-message-square-plus',
'text-n-blue-text',
],
SLA_MISSED_FIRST_RESPONSE: ['i-lucide-heart-crack', 'text-n-ruby-11'],
SLA_MISSED_NEXT_RESPONSE: ['i-lucide-heart-crack', 'text-n-ruby-11'],
SLA_MISSED_RESOLUTION: ['i-lucide-heart-crack', 'text-n-ruby-11'],
};
@@ -39,28 +39,6 @@ function getAgentInformation(id) {
return agents?.find(agent => agent.id === Number(id));
}
// use for debuggin
function stringToFloat(inputString) {
if (!inputString) {
return 0.0;
}
// Sum the Unicode values of all characters
const unicodeSum = Array.from(inputString).reduce(
(sum, char) => sum + char.charCodeAt(0),
0
);
// Use a large prime number to create more variance
const prime = 2147483647; // Mersenne prime (2^31 - 1)
// Generate a hash-like value
const hashValue = unicodeSum * prime;
// Normalize to [0, 1] range
return (hashValue % 1000000) / 1000000.0;
}
const totalCount = computed(() => agents.length);
const tableData = computed(() => {
@@ -72,12 +50,8 @@ const tableData = computed(() => {
agent: agentInformation.name || agentInformation.available_name,
email: agentInformation.email,
thumbnail: agentInformation.thumbnail,
open:
agent.metric.open ||
Math.floor(stringToFloat(agentInformation.email) * 50),
unattended:
agent.metric.unattended ||
Math.floor(stringToFloat(agentInformation.email) * 30),
open: agent.metric.open ?? 0,
unattended: agent.metric.unattended ?? 0,
status: agentInformation.availability_status,
};
});
@@ -1,4 +1,5 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TeamsIndex from './Index.vue';
import CreateStepWrap from './Create/Index.vue';
@@ -28,6 +29,7 @@ export default {
name: 'settings_teams_list',
component: TeamsIndex,
meta: {
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
permissions: ['administrator'],
},
},
@@ -55,6 +57,7 @@ export default {
name: 'settings_teams_new',
component: CreateTeam,
meta: {
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
permissions: ['administrator'],
},
},
@@ -63,6 +66,7 @@ export default {
name: 'settings_teams_finish',
component: FinishSetup,
meta: {
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
permissions: ['administrator'],
},
},
@@ -70,6 +74,7 @@ export default {
path: ':teamId/agents',
name: 'settings_teams_add_agents',
meta: {
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
permissions: ['administrator'],
},
component: AddAgents,
@@ -85,6 +90,7 @@ export default {
name: 'settings_teams_edit',
component: EditTeam,
meta: {
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
permissions: ['administrator'],
},
},
@@ -93,6 +99,7 @@ export default {
name: 'settings_teams_edit_members',
component: EditAgents,
meta: {
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
permissions: ['administrator'],
},
},
@@ -100,6 +107,7 @@ export default {
path: 'finish',
name: 'settings_teams_edit_finish',
meta: {
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
permissions: ['administrator'],
},
component: FinishSetup,
@@ -1,4 +1,5 @@
import { sortComparator } from './helpers';
import camelcaseKeys from 'camelcase-keys';
export const getters = {
getNotifications($state) {
@@ -11,6 +12,13 @@ export const getters = {
);
return sortedNotifications;
},
getFilteredNotificationsV4: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
sortComparator(n1, n2, sortOrder)
);
return camelcaseKeys(sortedNotifications, { deep: true });
},
getNotificationById: $state => id => {
return $state.records[id] || {};
},
@@ -15,5 +15,4 @@ export const BUS_EVENTS = {
NEW_CONVERSATION_MODAL: 'newConversationModal',
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
TOGGLE_MESSAGE_BG: 'toggleMessageBackground',
};
+11 -7
View File
@@ -69,13 +69,8 @@ class Notification < ApplicationRecord
snoozed_until: snoozed_until,
meta: meta,
account_id: account_id
}
if primary_actor.present?
payload[:primary_actor] = primary_actor&.push_event_data
# TODO: Rename push_message_title to push_message_body
payload[:push_message_title] = push_message_body
end
payload.merge!(primary_actor_data) if primary_actor.present?
payload
end
@@ -123,7 +118,7 @@ class Notification < ApplicationRecord
when 'assigned_conversation_new_message', 'participating_conversation_new_message', 'conversation_mention'
message_body(secondary_actor)
when 'conversation_assignment', 'sla_missed_next_response', 'sla_missed_resolution'
message_body(conversation.messages.incoming.last)
message_body((conversation.messages.incoming.last || conversation.messages.outgoing.last))
else
''
end
@@ -190,4 +185,13 @@ class Notification < ApplicationRecord
def set_last_activity_at
self.last_activity_at = created_at
end
def primary_actor_data
{
primary_actor: primary_actor&.push_event_data,
# TODO: Rename push_message_title to push_message_body
push_message_title: push_message_body,
push_message_body: push_message_body
}
end
end
@@ -4,32 +4,17 @@ class Messages::NewMessageNotificationService
def perform
return unless message.notifiable?
notify_participating_users
notify_conversation_assignee
notify_participating_users
end
private
delegate :conversation, :sender, :account, to: :message
def notify_participating_users
participating_users = conversation.conversation_participants.map(&:user)
participating_users -= [sender] if sender.is_a?(User)
participating_users.uniq.each do |participant|
NotificationBuilder.new(
notification_type: 'participating_conversation_new_message',
user: participant,
account: account,
primary_actor: message.conversation,
secondary_actor: message
).perform
end
end
def notify_conversation_assignee
return if conversation.assignee.blank?
return if assignee_already_notified_via_participation?
return if already_notified?(conversation.assignee)
return if conversation.assignee == sender
NotificationBuilder.new(
@@ -41,12 +26,26 @@ class Messages::NewMessageNotificationService
).perform
end
def assignee_already_notified_via_participation?
return unless conversation.conversation_participants.map(&:user).include?(conversation.assignee)
def notify_participating_users
participating_users = conversation.conversation_participants.map(&:user)
participating_users -= [sender] if sender.is_a?(User)
# check whether participation notifcation is disabled for assignee
notification_setting = conversation.assignee.notification_settings.find_by(account_id: account.id)
notification_setting.public_send(:email_participating_conversation_new_message?) || notification_setting
.public_send(:push_participating_conversation_new_message?)
participating_users.uniq.each do |participant|
next if already_notified?(participant)
NotificationBuilder.new(
notification_type: 'participating_conversation_new_message',
user: participant,
account: account,
primary_actor: message.conversation,
secondary_actor: message
).perform
end
end
# The user could already have been notified via a mention or via assignment
# So we don't need to notify them again
def already_notified?(user)
conversation.notifications.exists?(user: user, secondary_actor: message)
end
end
@@ -10,6 +10,7 @@ json.data do
json.id notification.id
json.notification_type notification.notification_type
json.push_message_title notification.push_message_title
json.push_message_body notification.push_message_body
# TODO: front end assumes primary actor to be conversation. should fix in future
json.primary_actor_type notification.primary_actor_type
json.primary_actor_id notification.primary_actor_id
@@ -4,7 +4,7 @@
"details": [
{
"appID": "<%= ENV['IOS_APP_ID'] %>",
"paths": [ "NOT /super_admin/*", "*" ]
"paths": [ "/app/accounts/*/conversations/*"]
}
]
}
+42 -1
View File
@@ -154,12 +154,53 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
# Throttle by individual user (based on uid)
throttle('/api/v2/accounts/:account_id/reports/user', limit: ENV.fetch('RATE_LIMIT_REPORTS_API_USER_LEVEL', '100').to_i, period: 1.minute) do |req|
match_data = %r{/api/v2/accounts/(?<account_id>\d+)/reports}.match(req.path)
# Extract user identification (uid for web, api_access_token for API requests)
user_uid = req.get_header('HTTP_UID')
api_access_token = req.get_header('HTTP_API_ACCESS_TOKEN') || req.get_header('api_access_token')
# Use uid if present, otherwise fallback to api_access_token for tracking
user_identifier = user_uid.presence || api_access_token.presence
"#{user_identifier}:#{match_data[:account_id]}" if match_data.present? && user_identifier.present?
end
## Prevent abuse of reports api at account level
throttle('/api/v2/accounts/:account_id/reports', limit: ENV.fetch('RATE_LIMIT_REPORTS_API_ACCOUNT_LEVEL', '1000').to_i, period: 1.minute) do |req|
match_data = %r{/api/v2/accounts/(?<account_id>\d+)/reports}.match(req.path)
match_data[:account_id] if match_data.present?
end
## ----------------------------------------------- ##
end
# Log blocked events
ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start, _finish, _request_id, payload|
Rails.logger.warn "[Rack::Attack][Blocked] remote_ip: \"#{payload[:request].remote_ip}\", path: \"#{payload[:request].path}\""
req = payload[:request]
user_uid = req.get_header('HTTP_UID')
api_access_token = req.get_header('HTTP_API_ACCESS_TOKEN') || req.get_header('api_access_token')
# Mask the token if present
masked_api_token = api_access_token.present? ? "#{api_access_token[0..4]}...[REDACTED]" : nil
# Use uid if present, otherwise fallback to masked api_access_token for tracking
user_identifier = user_uid.presence || masked_api_token.presence || 'unknown_user'
# Extract account ID if present
account_match = %r{/accounts/(?<account_id>\d+)}.match(req.path)
account_id = account_match ? account_match[:account_id] : 'unknown_account'
Rails.logger.warn(
"[Rack::Attack][Blocked] remote_ip: \"#{req.remote_ip}\", " \
"path: \"#{req.path}\", " \
"user_identifier: \"#{user_identifier}\", " \
"account_id: \"#{account_id}\", " \
"method: \"#{req.request_method}\", " \
"user_agent: \"#{req.user_agent}\""
)
end
Rack::Attack.enabled = Rails.env.production? ? ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) : false
+1 -1
View File
@@ -461,8 +461,8 @@ Rails.application.routes.draw do
# ----------------------------------------------------------------------
# Routes for external service verifications
get 'apple-app-site-association' => 'apple_app#site_association'
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
get '.well-known/apple-app-site-association' => 'apple_app#site_association'
get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_association'
# ----------------------------------------------------------------------
+1
View File
@@ -39,6 +39,7 @@
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@highlightjs/vue-plugin": "^2.1.0",
"@iconify-json/material-symbols": "^1.2.10",
"@june-so/analytics-next": "^2.0.0",
"@lk77/vue3-color": "^3.0.6",
"@radix-ui/colors": "^3.0.0",
+10
View File
@@ -40,6 +40,9 @@ importers:
'@highlightjs/vue-plugin':
specifier: ^2.1.0
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
'@iconify-json/material-symbols':
specifier: ^1.2.10
version: 1.2.10
'@june-so/analytics-next':
specifier: ^2.0.0
version: 2.0.0
@@ -890,6 +893,9 @@ packages:
'@iconify-json/lucide@1.2.11':
resolution: {integrity: sha512-dqpbV7+g1qqxtZOHCZKwdKhtYYqEUjFhYiOg/+PcADbjtapoL+bwa1Brn12gAHq5r2K7Mf29xRHOTmZ3UHHOrw==}
'@iconify-json/material-symbols@1.2.10':
resolution: {integrity: sha512-GcZxhOFStM7Dk/oZvJSaW0tR/k6NwTq+KDzYgCNBDg52ktZuRa/gkjRiYooJm/8PAe9NBYxIx8XjS/wi4sasdQ==}
'@iconify-json/ph@1.2.1':
resolution: {integrity: sha512-x0DNfwWrS18dbsBYOq3XGiZnGz4CgRyC+YSl/TZvMQiKhIUl1woWqUbMYqqfMNUBzjyk7ulvaRovpRsIlqIf8g==}
@@ -5784,6 +5790,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/material-symbols@1.2.10':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ph@1.2.1':
dependencies:
'@iconify/types': 2.0.0
+27 -1
View File
@@ -56,7 +56,7 @@ describe NotificationBuilder do
).to be_nil
end
it 'will create a conversation_mention notification eventhough user is not subscribed to it' do
it 'will create a conversation_mention notification even though user is not subscribed to it' do
notification_setting = user.notification_settings.find_by(account_id: account.id)
notification_setting.selected_email_flags = []
notification_setting.selected_push_flags = []
@@ -71,5 +71,31 @@ describe NotificationBuilder do
).perform
end.to change { user.notifications.count }.by(1)
end
it 'will not create a notification if conversation contact is blocked and notification type is not conversation_mention' do
primary_actor.contact.update(blocked: true)
expect do
described_class.new(
notification_type: 'conversation_creation',
user: user,
account: account,
primary_actor: primary_actor
).perform
end.not_to(change { user.notifications.count })
end
it 'will create a notification if conversation contact is blocked and notification type is conversation_mention' do
primary_actor.contact.update(blocked: true)
expect do
described_class.new(
notification_type: 'conversation_mention',
user: user,
account: account,
primary_actor: primary_actor
).perform
end.to change { user.notifications.count }.by(1)
end
end
end
+3 -3
View File
@@ -1,9 +1,9 @@
require 'rails_helper'
describe '/apple-app-site-association', type: :request do
describe 'GET /apple-app-site-association' do
describe '.well-known/apple-app-site-association', type: :request do
describe 'GET /.well-known/apple-app-site-association' do
it 'renders the apple-app-site-association file' do
get '/apple-app-site-association'
get '/.well-known/apple-app-site-association'
expect(response).to have_http_status(:success)
end
end
@@ -120,6 +120,51 @@ describe NotificationListener do
end
end
# integration tests to ensure that the order mention service and new message notification service are called in the correct order
describe 'message_created - mentions, participation & assignment integration' do
let(:event_name) { :'message.created' }
it 'will not create duplicate new message notification for the same user for mentions participation & assignment' do
create(:inbox_member, user: first_agent, inbox: inbox)
conversation.update(assignee: first_agent)
message = build(
:message,
conversation: conversation,
account: account,
content: "hi [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})",
private: true
)
event = Events::Base.new(event_name, Time.zone.now, message: message)
listener.message_created(event)
expect(first_agent.notifications.count).to eq(1)
expect(first_agent.notifications.first.notification_type).to eq('conversation_mention')
end
it 'will not create duplicate new message notifications for assignment & participation' do
create(:inbox_member, user: first_agent, inbox: inbox)
conversation.update(assignee: first_agent)
# participants is created by async job. so creating it directly for testcase
conversation.conversation_participants.first_or_create(user: first_agent)
message = build(
:message,
conversation: conversation,
account: account,
content: 'hi',
private: true
)
event = Events::Base.new(event_name, Time.zone.now, message: message)
listener.message_created(event)
expect(conversation.conversation_participants.map(&:user)).to include(first_agent)
expect(first_agent.notifications.count).to eq(1)
expect(first_agent.notifications.first.notification_type).to eq('assigned_conversation_new_message')
end
end
describe 'conversation_bot_handoff' do
let(:event_name) { :'conversation.bot_handoff' }
+8
View File
@@ -83,6 +83,14 @@ has been assigned to you"
expect(notification.push_message_body).to eq "#{message.sender.name}: #{message.content.truncate_words(10)}"
end
it 'returns appropriate body suited for the notification type conversation_assignment with outgoing message only' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2), message_type: :outgoing,
conversation: conversation)
notification = create(:notification, notification_type: 'conversation_assignment', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: #{message.content.truncate_words(10)}"
end
it 'returns appropriate body suited for the notification type assigned_conversation_new_message' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2), conversation: conversation)
@@ -16,7 +16,6 @@ describe Messages::NewMessageNotificationService do
let(:participating_agent_2) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: assignee) }
let(:builder) { double }
before do
create(:inbox_member, inbox: inbox, user: participating_agent_1)
@@ -25,8 +24,6 @@ describe Messages::NewMessageNotificationService do
create(:conversation_participant, conversation: conversation, user: participating_agent_1)
create(:conversation_participant, conversation: conversation, user: participating_agent_2)
create(:conversation_participant, conversation: conversation, user: assignee)
allow(NotificationBuilder).to receive(:new).and_return(builder)
allow(builder).to receive(:perform)
end
context 'when message is created by a participant' do
@@ -37,27 +34,19 @@ describe Messages::NewMessageNotificationService do
end
it 'creates notifications for other participating users' do
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'participating_conversation_new_message',
user: participating_agent_2,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(participating_agent_2.notifications.where(notification_type: 'participating_conversation_new_message', account: account,
primary_actor: message.conversation, secondary_actor: message)).to exist
end
it 'creates notifications for assignee' do
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'assigned_conversation_new_message',
user: assignee,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message', account: account,
primary_actor: message.conversation, secondary_actor: message)).to exist
end
it 'will not create notifications for the user who created the message' do
expect(NotificationBuilder).not_to have_received(:new).with(notification_type: 'participating_conversation_new_message',
user: participating_agent_1,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(participating_agent_1.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).not_to exist
end
end
@@ -69,42 +58,34 @@ describe Messages::NewMessageNotificationService do
end
it 'creates notifications for assignee' do
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'assigned_conversation_new_message',
user: assignee,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message', account: account,
primary_actor: message.conversation, secondary_actor: message)).to exist
end
it 'creates notifications for all participating users' do
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'participating_conversation_new_message',
user: participating_agent_1,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'participating_conversation_new_message',
user: participating_agent_2,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(participating_agent_1.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).to exist
expect(participating_agent_2.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).to exist
end
end
context 'with multiple notifications are subscribed' do
context 'when multiple notification conditions are met' do
let(:message) { create(:message, conversation: conversation, account: account) }
before do
assignee.notification_settings.find_by(account_id: account.id).update(selected_email_flags: %w[email_assigned_conversation_new_message
email_participating_conversation_new_message])
described_class.new(message: message).perform
end
it 'will not create assignee notifications for the assignee if participating notification was send' do
expect(NotificationBuilder).not_to have_received(:new).with(notification_type: 'assigned_conversation_new_message',
user: assignee,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
it 'will not create participating notifications for the assignee if assignee notification was send' do
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).to exist
expect(assignee.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).not_to exist
end
end
@@ -116,16 +97,12 @@ describe Messages::NewMessageNotificationService do
end
it 'will not create notifications for the user who created the message' do
expect(NotificationBuilder).not_to have_received(:new).with(notification_type: 'participating_conversation_new_message',
user: assignee,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(NotificationBuilder).not_to have_received(:new).with(notification_type: 'assigned_conversation_new_message',
user: assignee,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(assignee.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).not_to exist
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).not_to exist
end
end
end
+33 -8
View File
@@ -32,6 +32,7 @@ const tailwindConfig = {
'./app/javascript/dashboard/components-next/**/*.vue',
'./app/javascript/dashboard/helper/**/*.js',
'./app/javascript/dashboard/components-next/**/*.js',
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
'./app/views/**/*.html.erb',
],
theme: {
@@ -42,7 +43,7 @@ const tailwindConfig = {
interDisplay: ['Inter Display', ...defaultSansFonts],
},
typography: {
email: {
bubble: {
css: {
color: 'rgb(var(--slate-12))',
lineHeight: '1.6',
@@ -53,6 +54,10 @@ const tailwindConfig = {
},
},
'br + br': {
display: 'none',
},
strong: {
color: 'rgb(var(--slate-12))',
fontWeight: '700',
@@ -92,29 +97,40 @@ const tailwindConfig = {
marginBottom: '1.5em',
},
a: {
color: 'rgb(var(--text-blue))',
color: 'rgb(var(--slate-12))',
textDecoration: 'underline',
},
ul: {
paddingInlineStart: '0.625em',
},
ol: {
paddingInlineStart: '0.625em',
},
'ul li': {
margin: '0 0 0.5em 1.5em',
margin: '0 0 0.5em 1em',
listStyleType: 'disc',
},
'ol li': {
margin: '0 0 0.5em 1.5em',
margin: '0 0 0.5em 1em',
listStyleType: 'decimal',
},
blockquote: {
fontStyle: 'italic',
color: 'rgb(var(--slate-11))',
borderLeft: `4px solid rgb(var(--border-strong))`,
borderLeft: `4px solid rgb(var(--black-alpha-1))`,
paddingLeft: '1em',
},
code: {
backgroundColor: 'rgb(var(--alpha-3))',
color: 'rgb(var(--solid-amber))',
color: 'rgb(var(--slate-11))',
padding: '0.2em 0.4em',
borderRadius: '4px',
fontSize: '0.95em',
'&::before': {
content: `none`,
},
'&::after': {
content: `none`,
},
},
pre: {
backgroundColor: 'rgb(var(--alpha-3))',
@@ -143,6 +159,8 @@ const tailwindConfig = {
img: {
maxWidth: '100%',
height: 'auto',
marginTop: 'unset',
marginBottom: 'unset',
},
},
},
@@ -220,7 +238,14 @@ const tailwindConfig = {
iconsPlugin({
collections: {
woot: { icons },
...getIconCollections(['lucide', 'logos', 'ri', 'ph', 'teenyicons']),
...getIconCollections([
'lucide',
'logos',
'ri',
'ph',
'material-symbols',
'teenyicons',
]),
},
}),
],