chore: Add new tab and copy link to conversation context menu (#12089)

# Pull Request Template

## Description

This PR includes the following enhancements to the conversation card
context menu:

1. **Added "Open in New Tab" and "Copy Conversation Link" options.**
* "Open in New Tab" allows users to quickly open a conversation in a
separate browser tab.
* "Copy Conversation Link" copies the conversation URL to the clipboard
for easy sharing.

2. **Enabled the context menu in Previous Conversations card** with
support for these two options.

Fixes
https://linear.app/chatwoot/issue/CW-4722/cannot-open-previous-conversations-in-a-new-tab

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/37b45d23c6804db292568d093b645ac0?sid=c3105971-f938-41bd-9f52-0f00d419d1b3


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Sivin Varghese
2025-08-05 03:52:20 +05:30
committed by GitHub
parent 53fce7be03
commit 270f26e471
5 changed files with 206 additions and 103 deletions

View File

@@ -68,6 +68,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
allowedContextMenuOptions: {
type: Array,
default: () => [],
},
}, },
emits: [ emits: [
'contextMenuToggle', 'contextMenuToggle',
@@ -151,11 +155,9 @@ export default {
hasSlaPolicyId() { hasSlaPolicyId() {
return this.chat?.sla_policy_id; return this.chat?.sla_policy_id;
}, },
}, conversationPath() {
methods: {
onCardClick(e) {
const { activeInbox, chat } = this; const { activeInbox, chat } = this;
const path = frontendURL( return frontendURL(
conversationUrl({ conversationUrl({
accountId: this.accountId, accountId: this.accountId,
activeInbox, activeInbox,
@@ -166,18 +168,26 @@ export default {
conversationType: this.conversationType, conversationType: this.conversationType,
}) })
); );
},
},
methods: {
onCardClick(e) {
const path = this.conversationPath;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) { if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open( window.open(
window.chatwootConfig.hostURL + path, `${window.chatwootConfig.hostURL}${path}`,
'_blank', '_blank',
'noopener noreferrer nofollow' 'noopener,noreferrer'
); );
return; return;
} }
if (this.isActiveChat) {
return; // Skip if already active
} if (this.isActiveChat) return;
router.push({ path }); router.push({ path });
}, },
@@ -359,6 +369,8 @@ export default {
:priority="chat.priority" :priority="chat.priority"
:chat-id="chat.id" :chat-id="chat.id"
:has-unread-messages="hasUnread" :has-unread-messages="hasUnread"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation" @update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent" @assign-agent="onAssignAgent"
@assign-label="onAssignLabel" @assign-label="onAssignLabel"
@@ -367,6 +379,7 @@ export default {
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation" @delete-conversation="deleteConversation"
@close="closeContextMenu"
/> />
</ContextMenu> </ContextMenu>
</div> </div>

View File

@@ -1,5 +1,8 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { import {
getSortedAgentsByAvailability, getSortedAgentsByAvailability,
getAgentsByUpdatedPresence, getAgentsByUpdatedPresence,
@@ -8,7 +11,20 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
const MENU = {
MARK_AS_READ: 'mark-as-read',
MARK_AS_UNREAD: 'mark-as-unread',
PRIORITY: 'priority',
STATUS: 'status',
SNOOZE: 'snooze',
AGENT: 'agent',
TEAM: 'team',
LABEL: 'label',
DELETE: 'delete',
OPEN_NEW_TAB: 'open-new-tab',
COPY_LINK: 'copy-link',
};
export default { export default {
components: { components: {
@@ -37,6 +53,14 @@ export default {
type: String, type: String,
default: null, default: null,
}, },
conversationUrl: {
type: String,
default: '',
},
allowedOptions: {
type: Array,
default: () => [],
},
}, },
emits: [ emits: [
'updateConversation', 'updateConversation',
@@ -47,6 +71,7 @@ export default {
'assignTeam', 'assignTeam',
'assignLabel', 'assignLabel',
'deleteConversation', 'deleteConversation',
'close',
], ],
setup() { setup() {
const { isAdmin } = useAdmin(); const { isAdmin } = useAdmin();
@@ -56,6 +81,7 @@ export default {
}, },
data() { data() {
return { return {
MENU,
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: { readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -63,7 +89,7 @@ export default {
}, },
unreadOption: { unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail', icon: 'mail-unread',
}, },
statusMenuConfig: [ statusMenuConfig: [
{ {
@@ -88,7 +114,7 @@ export default {
icon: 'snooze', icon: 'snooze',
}, },
priorityConfig: { priorityConfig: {
key: 'priority', key: MENU.PRIORITY,
label: this.$t('CONVERSATION.PRIORITY.TITLE'), label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning', icon: 'warning',
options: [ options: [
@@ -115,25 +141,35 @@ export default {
].filter(item => item.key !== this.priority), ].filter(item => item.key !== this.priority),
}, },
labelMenuConfig: { labelMenuConfig: {
key: 'label', key: MENU.LABEL,
icon: 'tag', icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
}, },
agentMenuConfig: { agentMenuConfig: {
key: 'agent', key: MENU.AGENT,
icon: 'person-add', icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
}, },
teamMenuConfig: { teamMenuConfig: {
key: 'team', key: MENU.TEAM,
icon: 'people-team-add', icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
}, },
deleteOption: { deleteOption: {
key: 'delete', key: MENU.DELETE,
icon: 'delete', icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
}, },
openInNewTabOption: {
key: MENU.OPEN_NEW_TAB,
icon: 'open',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
},
copyLinkOption: {
key: MENU.COPY_LINK,
icon: 'copy',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
},
}; };
}, },
computed: { computed: {
@@ -180,6 +216,10 @@ export default {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]); this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
}, },
methods: { methods: {
isAllowed(keys) {
if (!this.allowedOptions.length) return true;
return keys.some(key => this.allowedOptions.includes(key));
},
toggleStatus(status, snoozedUntil) { toggleStatus(status, snoozedUntil) {
this.$emit('updateConversation', status, snoozedUntil); this.$emit('updateConversation', status, snoozedUntil);
}, },
@@ -194,6 +234,24 @@ export default {
deleteConversation() { deleteConversation() {
this.$emit('deleteConversation', this.chatId); this.$emit('deleteConversation', this.chatId);
}, },
openInNewTab() {
if (!this.conversationUrl) return;
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
this.$emit('close');
},
async copyConversationLink() {
if (!this.conversationUrl) return;
try {
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
await copyTextToClipboard(url);
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
this.$emit('close');
} catch (error) {
// error
}
},
show(key) { show(key) {
// If the conversation status is same as the action, then don't display the option // If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved. // i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -217,83 +275,114 @@ export default {
</script> </script>
<template> <template>
<div class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px]"> <div
<MenuItem class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
v-if="!hasUnreadMessages" >
:option="unreadOption" <template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
variant="icon"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<template v-for="option in statusMenuConfig">
<MenuItem <MenuItem
v-if="show(option.key)" v-if="!hasUnreadMessages"
:key="option.key" :option="unreadOption"
:option="option"
variant="icon" variant="icon"
@click.stop="toggleStatus(option.key, null)" @click.stop="$emit('markAsUnread')"
/> />
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template> </template>
<MenuItem <template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
v-if="showSnooze" <template v-for="option in statusMenuConfig">
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItemWithSubmenu :option="priorityConfig">
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem <MenuItem
v-for="agent in assignableAgents" v-if="show(option.key) && isAllowed([MENU.STATUS])"
:key="agent.id" :key="option.key"
:option="generateMenuLabelConfig(agent, 'agent')" :option="option"
variant="agent" variant="icon"
@click.stop="$emit('assignAgent', agent)" @click.stop="toggleStatus(option.key, null)"
/> />
</template> </template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem <MenuItem
v-for="team in teams" v-if="showSnooze && isAllowed([MENU.SNOOZE])"
:key="team.id" :option="snoozeOption"
:option="generateMenuLabelConfig(team, 'team')" variant="icon"
@click.stop="$emit('assignTeam', team)" @click.stop="snoozeConversation()"
/> />
</MenuItemWithSubmenu> <hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<template v-if="isAdmin"> </template>
<template
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.PRIORITY])"
:option="priorityConfig"
>
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.LABEL])"
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.TEAM])"
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
<MenuItem
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
:option="openInNewTabOption"
variant="icon"
@click.stop="openInNewTab"
/>
<MenuItem
v-if="isAllowed([MENU.COPY_LINK])"
:option="copyLinkOption"
variant="icon"
@click.stop="copyConversationLink"
/>
</template>
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" /> <hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem <MenuItem
:option="deleteOption" :option="deleteOption"

View File

@@ -1,20 +1,16 @@
<script> <script setup>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
export default {
components: { defineProps({
Thumbnail, option: {
type: Object,
default: () => {},
}, },
props: { variant: {
option: { type: String,
type: Object, default: 'default',
default: () => {},
},
variant: {
type: String,
default: 'default',
},
}, },
}; });
</script> </script>
<template> <template>
@@ -30,12 +26,12 @@ export default {
class="label-pill flex-shrink-0" class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }" :style="{ backgroundColor: option.color }"
/> />
<Thumbnail <Avatar
v-if="variant === 'agent'" v-if="variant === 'agent'"
:username="option.label" :name="option.label"
:src="option.thumbnail" :src="option.thumbnail"
:status="option.status" :status="option.status === 'online' ? option.status : null"
size="20px" :size="20"
class="flex-shrink-0" class="flex-shrink-0"
/> />
<p class="menu-label truncate min-w-0 flex-1"> <p class="menu-label truncate min-w-0 flex-1">

View File

@@ -144,6 +144,9 @@
"AGENTS_LOADING": "Loading agents...", "AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team", "ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation", "DELETE": "Delete conversation",
"OPEN_IN_NEW_TAB": "Open in new tab",
"COPY_LINK": "Copy conversation link",
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
"API": { "API": {
"AGENT_ASSIGNMENT": { "AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"", "SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",

View File

@@ -60,6 +60,8 @@ export default {
:chat="conversation" :chat="conversation"
:hide-inbox-name="false" :hide-inbox-name="false"
hide-thumbnail hide-thumbnail
enable-context-menu
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
class="compact" class="compact"
/> />
</div> </div>