Merge branch 'develop' into feat/captain-toggle-updates
This commit is contained in:
@@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message)"
|
||||
v-dompurify-html="formatMessage(message, false, false, false)"
|
||||
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
||||
/>
|
||||
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
||||
|
||||
@@ -171,20 +171,24 @@ const menuItems = computed(() => {
|
||||
name: 'Captain',
|
||||
icon: 'i-woot-captain',
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
showOnlyOnCloud: true,
|
||||
children: [
|
||||
{
|
||||
name: 'Assistants',
|
||||
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_assistants_index'),
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_documents_index'),
|
||||
},
|
||||
{
|
||||
name: 'Responses',
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_responses_index'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
// import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import AssistantIndex from './assistants/Index.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
|
||||
|
||||
@@ -148,7 +148,6 @@ onMounted(fetchAccountDetails);
|
||||
/>
|
||||
</div>
|
||||
</BillingCard>
|
||||
|
||||
<BillingHeader
|
||||
class="px-1 mt-5"
|
||||
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
|
||||
|
||||
@@ -24,7 +24,8 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
buttonClassName() {
|
||||
let className = 'text-white py-3 px-4 rounded shadow-sm leading-4';
|
||||
let className =
|
||||
'text-white py-3 px-4 rounded shadow-sm leading-4 cursor-pointer disabled:opacity-50';
|
||||
if (this.type === 'clear') {
|
||||
className = 'flex mx-auto mt-4 text-xs leading-3 w-auto text-black-600';
|
||||
}
|
||||
|
||||
@@ -30,6 +30,18 @@ describe('useMessageFormatter', () => {
|
||||
'<a href="https://twitter.com/hashtag/hashtag"'
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable link formatting when linkify is false', () => {
|
||||
const message = 'Check https://example.com and {{user.id}}';
|
||||
const result = messageFormatter.formatMessage(
|
||||
message,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
);
|
||||
expect(result).not.toContain('<a href="https://example.com"');
|
||||
expect(result).toContain('{{user.id}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateMessage', () => {
|
||||
|
||||
@@ -14,11 +14,13 @@ export const useMessageFormatter = () => {
|
||||
* @param {boolean} isAPrivateNote - Whether the message is a private note.
|
||||
* @returns {string} - The formatted message.
|
||||
*/
|
||||
const formatMessage = (message, isATweet, isAPrivateNote) => {
|
||||
// TODO: ref:https://github.com/chatwoot/chatwoot/pull/10725#discussion_r1925300874
|
||||
const formatMessage = (message, isATweet, isAPrivateNote, linkify) => {
|
||||
const messageFormatter = new MessageFormatter(
|
||||
message,
|
||||
isATweet,
|
||||
isAPrivateNote
|
||||
isAPrivateNote,
|
||||
linkify
|
||||
);
|
||||
return messageFormatter.formattedMessage;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import mila from 'markdown-it-link-attributes';
|
||||
import mentionPlugin from './markdownIt/link';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
const setImageHeight = inlineToken => {
|
||||
const imgSrc = inlineToken.attrGet('src');
|
||||
if (!imgSrc) return;
|
||||
@@ -30,25 +31,27 @@ const imgResizeManager = md => {
|
||||
});
|
||||
};
|
||||
|
||||
const md = MarkdownIt({
|
||||
html: false,
|
||||
xhtmlOut: true,
|
||||
breaks: true,
|
||||
langPrefix: 'language-',
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
quotes: '\u201c\u201d\u2018\u2019',
|
||||
maxNesting: 20,
|
||||
})
|
||||
.use(mentionPlugin)
|
||||
.use(imgResizeManager)
|
||||
.use(mila, {
|
||||
attrs: {
|
||||
class: 'link',
|
||||
rel: 'noreferrer noopener nofollow',
|
||||
target: '_blank',
|
||||
},
|
||||
});
|
||||
const createMarkdownInstance = (linkify = true) => {
|
||||
return MarkdownIt({
|
||||
html: false,
|
||||
xhtmlOut: true,
|
||||
breaks: true,
|
||||
langPrefix: 'language-',
|
||||
linkify,
|
||||
typographer: true,
|
||||
quotes: '\u201c\u201d\u2018\u2019',
|
||||
maxNesting: 20,
|
||||
})
|
||||
.use(mentionPlugin)
|
||||
.use(imgResizeManager)
|
||||
.use(mila, {
|
||||
attrs: {
|
||||
class: 'link',
|
||||
rel: 'noreferrer noopener nofollow',
|
||||
target: '_blank',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
||||
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
|
||||
@@ -56,10 +59,17 @@ const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
|
||||
const TWITTER_HASH_REPLACEMENT = '$1[#$2](https://twitter.com/hashtag/$2)';
|
||||
|
||||
class MessageFormatter {
|
||||
constructor(message, isATweet = false, isAPrivateNote = false) {
|
||||
constructor(
|
||||
message,
|
||||
isATweet = false,
|
||||
isAPrivateNote = false,
|
||||
linkify = true
|
||||
) {
|
||||
this.message = message || '';
|
||||
this.isAPrivateNote = isAPrivateNote;
|
||||
this.isATweet = isATweet;
|
||||
this.linkify = linkify;
|
||||
this.md = createMarkdownInstance(linkify);
|
||||
}
|
||||
|
||||
formatMessage() {
|
||||
@@ -74,7 +84,7 @@ class MessageFormatter {
|
||||
TWITTER_HASH_REPLACEMENT
|
||||
);
|
||||
}
|
||||
return md.render(updatedMessage);
|
||||
return this.md.render(updatedMessage);
|
||||
}
|
||||
|
||||
get formattedMessage() {
|
||||
|
||||
@@ -16,6 +16,13 @@ describe('#MessageFormatter', () => {
|
||||
'<p>Chatwoot is an opensource tool. <a href="https://www.chatwoot.com" class="link" rel="noreferrer noopener nofollow" target="_blank">https://www.chatwoot.com</a></p>'
|
||||
);
|
||||
});
|
||||
it('should not convert template variables to links when linkify is disabled', () => {
|
||||
const message = 'Hey {{customer.name}}, check https://chatwoot.com';
|
||||
const formatter = new MessageFormatter(message, false, false, false);
|
||||
expect(formatter.formattedMessage).toMatch(
|
||||
'<p>Hey {{customer.name}}, check https://chatwoot.com</p>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parses heading to strong', () => {
|
||||
|
||||
@@ -52,9 +52,13 @@ export default {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
isCreating: 'conversation/getIsCreating',
|
||||
isConversationRouting: 'appConfig/getIsUpdatingRoute',
|
||||
activeCampaign: 'campaign/getActiveCampaign',
|
||||
currentUser: 'contacts/getCurrentUser',
|
||||
}),
|
||||
isCreatingConversation() {
|
||||
return this.isCreating || this.isConversationRouting;
|
||||
},
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
@@ -337,9 +341,9 @@ export default {
|
||||
block
|
||||
:bg-color="widgetColor"
|
||||
:text-color="textColor"
|
||||
:disabled="isCreating"
|
||||
:disabled="isCreatingConversation"
|
||||
>
|
||||
<Spinner v-if="isCreating" class="p-0" />
|
||||
<Spinner v-if="isCreatingConversation" class="p-0" />
|
||||
{{ $t('START_CONVERSATION') }}
|
||||
</CustomButton>
|
||||
</FormKit>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import ViewWithHeader from './components/layouts/ViewWithHeader.vue';
|
||||
import store from './store';
|
||||
|
||||
export default createRouter({
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
@@ -42,3 +43,35 @@ export default createRouter({
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigation Guards to Handle Route Transitions
|
||||
*
|
||||
* Purpose:
|
||||
* Prevents duplicate form submissions and API calls during route transitions,
|
||||
* especially important in high-latency scenarios.
|
||||
*
|
||||
* Flow:
|
||||
* 1. beforeEach: Sets isUpdatingRoute to true at start of navigation
|
||||
* 2. Component buttons/actions check this flag to prevent duplicate actions
|
||||
* 3. afterEach: Resets the flag once navigation is complete
|
||||
*
|
||||
* Implementation note:
|
||||
* Handling it globally, so that we can use it across all components
|
||||
* to ensure consistent UI behavior during all route transitions.
|
||||
*
|
||||
* @see https://github.com/chatwoot/chatwoot/issues/10736
|
||||
*/
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Prevent any user interactions during route transition
|
||||
await store.dispatch('appConfig/setRouteTransitionState', true);
|
||||
next();
|
||||
});
|
||||
|
||||
router.afterEach(() => {
|
||||
// Re-enable user interactions after navigation is complete
|
||||
store.dispatch('appConfig/setRouteTransitionState', false);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SET_WIDGET_APP_CONFIG,
|
||||
SET_WIDGET_COLOR,
|
||||
TOGGLE_WIDGET_OPEN,
|
||||
SET_ROUTE_UPDATE_STATE,
|
||||
} from '../types';
|
||||
|
||||
const state = {
|
||||
@@ -19,6 +20,7 @@ const state = {
|
||||
widgetColor: '',
|
||||
widgetStyle: 'standard',
|
||||
darkMode: 'light',
|
||||
isUpdatingRoute: false,
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -31,6 +33,7 @@ export const getters = {
|
||||
isWidgetStyleFlat: $state => $state.widgetStyle === 'flat',
|
||||
darkMode: $state => $state.darkMode,
|
||||
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
|
||||
getIsUpdatingRoute: _state => _state.isUpdatingRoute,
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -69,6 +72,13 @@ export const actions = {
|
||||
setBubbleVisibility({ commit }, hideMessageBubble) {
|
||||
commit(SET_BUBBLE_VISIBILITY, hideMessageBubble);
|
||||
},
|
||||
setRouteTransitionState: async ({ commit }, status) => {
|
||||
// Handles the routing state during navigation to different screen
|
||||
// Called before the navigation starts and after navigation completes
|
||||
// Handling this state in app/javascript/widget/router.js
|
||||
// See issue: https://github.com/chatwoot/chatwoot/issues/10736
|
||||
commit(SET_ROUTE_UPDATE_STATE, status);
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -96,6 +106,9 @@ export const mutations = {
|
||||
[SET_COLOR_SCHEME]($state, darkMode) {
|
||||
$state.darkMode = darkMode;
|
||||
},
|
||||
[SET_ROUTE_UPDATE_STATE]($state, status) {
|
||||
$state.isUpdatingRoute = status;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -31,4 +31,11 @@ describe('#actions', () => {
|
||||
expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setRouteTransitionState', () => {
|
||||
it('creates actions properly', () => {
|
||||
actions.setRouteTransitionState({ commit }, false);
|
||||
expect(commit.mock.calls).toEqual([['SET_ROUTE_UPDATE_STATE', false]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,4 +19,10 @@ describe('#getters', () => {
|
||||
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getIsUpdatingRoute', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { isUpdatingRoute: true };
|
||||
expect(getters.getIsUpdatingRoute(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,4 +32,12 @@ describe('#mutations', () => {
|
||||
expect(state.darkMode).toEqual('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_ROUTE_UPDATE_STATE', () => {
|
||||
it('sets dark mode properly', () => {
|
||||
const state = { isUpdatingRoute: false };
|
||||
mutations.SET_ROUTE_UPDATE_STATE(state, true);
|
||||
expect(state.isUpdatingRoute).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('#actions', () => {
|
||||
messages: [{ id: 1, content: 'This is a test message' }],
|
||||
},
|
||||
});
|
||||
|
||||
let windowSpy = vi.spyOn(window, 'window', 'get');
|
||||
windowSpy.mockImplementation(() => ({
|
||||
WOOT_WIDGET: {
|
||||
|
||||
@@ -7,3 +7,4 @@ export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
|
||||
export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN';
|
||||
export const SET_REFERRER_HOST = 'SET_REFERRER_HOST';
|
||||
export const SET_BUBBLE_VISIBILITY = 'SET_BUBBLE_VISIBILITY';
|
||||
export const SET_ROUTE_UPDATE_STATE = 'SET_ROUTE_UPDATE_STATE';
|
||||
|
||||
@@ -12,12 +12,20 @@ export default {
|
||||
},
|
||||
mixins: [configMixin, routerMixin],
|
||||
mounted() {
|
||||
emitter.on(ON_CONVERSATION_CREATED, () => {
|
||||
// Redirect to messages page after conversation is created
|
||||
this.replaceRoute('messages');
|
||||
});
|
||||
// Register event listener for conversation creation
|
||||
emitter.on(ON_CONVERSATION_CREATED, this.handleConversationCreated);
|
||||
},
|
||||
beforeUnmount() {
|
||||
emitter.off(ON_CONVERSATION_CREATED, this.handleConversationCreated);
|
||||
},
|
||||
methods: {
|
||||
handleConversationCreated() {
|
||||
// Redirect to messages page after conversation is created
|
||||
this.replaceRoute('messages');
|
||||
// Only after successful navigation, reset the isUpdatingRoute UIflag in app/javascript/widget/router.js
|
||||
// See issue: https://github.com/chatwoot/chatwoot/issues/10736
|
||||
},
|
||||
|
||||
onSubmit({
|
||||
fullName,
|
||||
emailAddress,
|
||||
|
||||
Reference in New Issue
Block a user