feat: Improve email rendering, introduce a new layout for emails (#5039)
Co-authored-by: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com>
This commit is contained in:
@@ -296,7 +296,7 @@
|
||||
@include margin($zero $space-normal);
|
||||
|
||||
--bubble-max-width: 49.6rem;
|
||||
max-width: Min(var(--bubble-max-width), 85%);
|
||||
max-width: Min(var(--bubble-max-width), 84%);
|
||||
|
||||
.sender--name {
|
||||
font-size: $font-size-mini;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="conversations-list-wrap">
|
||||
<div
|
||||
class="conversations-list-wrap"
|
||||
:class="{
|
||||
hide: !showConversationList,
|
||||
'list--full-width': isOnExpandedLayout,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
class="chat-list__top"
|
||||
@@ -46,7 +52,7 @@
|
||||
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.top-end="$t('FILTER.TOOLTIP_LABEL')"
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="filter"
|
||||
@@ -210,6 +216,14 @@ export default {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showConversationList: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
isOnExpandedLayout: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -696,6 +710,17 @@ export default {
|
||||
@include breakpoint(xxxlarge up) {
|
||||
flex-basis: 46rem;
|
||||
}
|
||||
|
||||
&.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.list--full-width {
|
||||
width: 100%;
|
||||
@include breakpoint(xxxlarge up) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter--actions {
|
||||
display: flex;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div class="conversation-details-wrap">
|
||||
<div
|
||||
class="conversation-details-wrap"
|
||||
:class="{ 'with-border-left': !isOnExpandedLayout }"
|
||||
>
|
||||
<conversation-header
|
||||
v-if="currentChat.id"
|
||||
:chat="currentChat"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:show-back-button="isOnExpandedLayout"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<woot-tabs
|
||||
@@ -26,7 +30,7 @@
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<empty-state v-else />
|
||||
<empty-state v-else :is-on-expanded-layout="isOnExpandedLayout" />
|
||||
<div v-show="showContactPanel" class="conversation-sidebar-wrap">
|
||||
<contact-panel
|
||||
v-if="showContactPanel"
|
||||
@@ -71,6 +75,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { activeIndex: 0 };
|
||||
@@ -134,8 +142,11 @@ export default {
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-background-light);
|
||||
|
||||
&.with-border-left {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-app--tabs {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="conv-header">
|
||||
<div class="user">
|
||||
<back-button v-if="showBackButton" :back-url="backButtonUrl" />
|
||||
<Thumbnail
|
||||
:src="currentContact.thumbnail"
|
||||
size="40px"
|
||||
@@ -47,19 +48,21 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import { mapGetters } from 'vuex';
|
||||
import MoreActions from './MoreActions';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import agentMixin from '../../../mixins/agentMixin.js';
|
||||
import BackButton from '../BackButton';
|
||||
import differenceInHours from 'date-fns/differenceInHours';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import wootConstants from '../../../constants';
|
||||
import differenceInHours from 'date-fns/differenceInHours';
|
||||
import InboxName from '../InboxName';
|
||||
|
||||
import MoreActions from './MoreActions';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import wootConstants from '../../../constants';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
export default {
|
||||
components: {
|
||||
BackButton,
|
||||
InboxName,
|
||||
MoreActions,
|
||||
Thumbnail,
|
||||
@@ -74,6 +77,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@@ -83,6 +90,19 @@ export default {
|
||||
chatMetadata() {
|
||||
return this.chat.meta;
|
||||
},
|
||||
backButtonUrl() {
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = this.$route;
|
||||
return conversationListPageURL({
|
||||
accountId,
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
conversationType: name === 'conversation_mentions' ? 'mention' : '',
|
||||
});
|
||||
},
|
||||
isHMACVerified() {
|
||||
if (!this.isAWebWidgetInbox) {
|
||||
return true;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<!-- No conversation selected -->
|
||||
<div v-else-if="allConversations.length && !currentChat.id">
|
||||
<img src="~dashboard/assets/images/chat.svg" alt="No Chat" />
|
||||
<span>{{ $t('CONVERSATION.404') }}</span>
|
||||
<span>{{ conversationMissingMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +51,12 @@ export default {
|
||||
OnboardingView,
|
||||
},
|
||||
mixins: [accountMixin, adminMixin],
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
@@ -65,6 +71,12 @@ export default {
|
||||
}
|
||||
return this.$t('CONVERSATION.LOADING_CONVERSATIONS');
|
||||
},
|
||||
conversationMissingMessage() {
|
||||
if (!this.isOnExpandedLayout) {
|
||||
return this.$t('CONVERSATION.SELECT_A_CONVERSATION');
|
||||
}
|
||||
return this.$t('CONVERSATION.404');
|
||||
},
|
||||
newInboxURL() {
|
||||
return this.addAccountScoping('settings/inboxes/new');
|
||||
},
|
||||
|
||||
@@ -170,7 +170,7 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
contentToBeParsed() {
|
||||
emailMessageContent() {
|
||||
const {
|
||||
html_content: { full: fullHTMLContent } = {},
|
||||
text_content: { full: fullTextContent } = {},
|
||||
@@ -182,13 +182,19 @@ export default {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.contentToBeParsed.includes('<blockquote')) {
|
||||
if (this.emailMessageContent.includes('<blockquote')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
message() {
|
||||
// If the message is an email, emailMessageContent would be present
|
||||
// In that case, we would use letter package to render the email
|
||||
if (this.emailMessageContent && this.isIncoming) {
|
||||
return this.emailMessageContent;
|
||||
}
|
||||
|
||||
const botMessageContent = generateBotMessageContent(
|
||||
this.contentType,
|
||||
this.contentAttributes,
|
||||
@@ -200,21 +206,6 @@ export default {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
email: { content_type: contentType = '' } = {},
|
||||
} = this.contentAttributes;
|
||||
if (this.contentToBeParsed && this.isIncoming) {
|
||||
const parsedContent = this.stripStyleCharacters(this.contentToBeParsed);
|
||||
if (parsedContent) {
|
||||
// This is a temporary fix for line-breaks in text/plain emails
|
||||
// Now, It is not rendered properly in the email preview.
|
||||
// FIXME: Remove this once we have a better solution for rendering text/plain emails
|
||||
return contentType.includes('text/plain')
|
||||
? parsedContent.replace(/\n/g, '<br />')
|
||||
: parsedContent;
|
||||
}
|
||||
}
|
||||
return (
|
||||
this.formatMessage(
|
||||
this.data.content,
|
||||
@@ -331,6 +322,7 @@ export default {
|
||||
'activity-wrap': !this.isBubble,
|
||||
'is-pending': this.isPending,
|
||||
'is-failed': this.isFailed,
|
||||
'is-email': this.isEmailContentType,
|
||||
};
|
||||
},
|
||||
bubbleClass() {
|
||||
@@ -342,6 +334,7 @@ export default {
|
||||
'is-text': this.hasText,
|
||||
'is-from-bot': this.isSentByBot,
|
||||
'is-failed': this.isFailed,
|
||||
'is-email': this.isEmailContentType,
|
||||
};
|
||||
},
|
||||
isPending() {
|
||||
@@ -518,6 +511,10 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.wrap.is-email {
|
||||
--bubble-max-width: 84% !important;
|
||||
}
|
||||
|
||||
.sender--info {
|
||||
align-items: center;
|
||||
color: var(--b-700);
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
'hide--quoted': !showQuotedContent,
|
||||
}"
|
||||
>
|
||||
<div v-dompurify-html="message" class="text-content" />
|
||||
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
|
||||
<letter v-else class="text-content" :html="message" />
|
||||
<button
|
||||
v-if="displayQuotedButton"
|
||||
class="quoted-text--button"
|
||||
@@ -25,7 +26,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Letter from 'vue-letter';
|
||||
|
||||
export default {
|
||||
components: { Letter },
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
@@ -65,14 +69,16 @@ export default {
|
||||
padding-left: var(--space-two);
|
||||
}
|
||||
table {
|
||||
all: revert;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
|
||||
td {
|
||||
all: revert;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
tr {
|
||||
all: revert;
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ export default {
|
||||
SNOOZED: 'snoozed',
|
||||
ALL: 'all',
|
||||
},
|
||||
LAYOUT_TYPES: {
|
||||
CONDENSED: 'condensed',
|
||||
EXPANDED: 'expanded',
|
||||
},
|
||||
DOCS_URL: '//www.chatwoot.com/docs/product/',
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
|
||||
@@ -44,6 +44,26 @@ export const conversationUrl = ({
|
||||
return url;
|
||||
};
|
||||
|
||||
export const conversationListPageURL = ({
|
||||
accountId,
|
||||
conversationType = '',
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
}) => {
|
||||
let url = `accounts/${accountId}/dashboard`;
|
||||
if (label) {
|
||||
url = `accounts/${accountId}/label/${label}`;
|
||||
} else if (teamId) {
|
||||
url = `accounts/${accountId}/team/${teamId}`;
|
||||
} else if (conversationType === 'mention') {
|
||||
url = `accounts/${accountId}/mentions/conversations`;
|
||||
} else if (inboxId) {
|
||||
url = `accounts/${accountId}/inbox/${inboxId}`;
|
||||
}
|
||||
return frontendURL(url);
|
||||
};
|
||||
|
||||
export const isValidURL = value => {
|
||||
/* eslint-disable no-useless-escape */
|
||||
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;
|
||||
|
||||
@@ -3,9 +3,33 @@ import {
|
||||
conversationUrl,
|
||||
isValidURL,
|
||||
getLoginRedirectURL,
|
||||
conversationListPageURL,
|
||||
} from '../URLHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
describe('conversationListPageURL', () => {
|
||||
it('should return url to dashboard', () => {
|
||||
expect(conversationListPageURL({ accountId: 1 })).toBe(
|
||||
'/app/accounts/1/dashboard'
|
||||
);
|
||||
});
|
||||
it('should return url to inbox', () => {
|
||||
expect(conversationListPageURL({ accountId: 1, inboxId: 1 })).toBe(
|
||||
'/app/accounts/1/inbox/1'
|
||||
);
|
||||
});
|
||||
it('should return url to label', () => {
|
||||
expect(conversationListPageURL({ accountId: 1, label: 'support' })).toBe(
|
||||
'/app/accounts/1/label/support'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return url to team', () => {
|
||||
expect(conversationListPageURL({ accountId: 1, teamId: 1 })).toBe(
|
||||
'/app/accounts/1/team/1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('conversationUrl', () => {
|
||||
it('should return direct conversation URL if activeInbox is nil', () => {
|
||||
expect(conversationUrl({ accountId: 1, id: 1 })).toBe(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"CONVERSATION": {
|
||||
"404": "Please select a conversation from left pane",
|
||||
"SELECT_A_CONVERSATION": "Please select a conversation from left pane",
|
||||
"404": "Sorry, we cannot find the conversation. Please try again",
|
||||
"SWITCH_VIEW_LAYOUT": "Switch the layout",
|
||||
"DASHBOARD_APP_TAB_MESSAGES": "Messages",
|
||||
"UNVERIFIED_SESSION": "The identity of this user is not verified",
|
||||
"NO_MESSAGE_1": "Uh oh! Looks like there are no messages from customers in your inbox.",
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
<template>
|
||||
<section class="conversation-page">
|
||||
<chat-list
|
||||
:show-conversation-list="showConversationList"
|
||||
:conversation-inbox="inboxId"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:conversation-type="conversationType"
|
||||
:folders-id="foldersId"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@conversation-load="onConversationLoad"
|
||||
>
|
||||
<pop-over-search />
|
||||
<pop-over-search
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@toggle-conversation-layout="toggleConversationLayout"
|
||||
/>
|
||||
</chat-list>
|
||||
<conversation-box
|
||||
v-if="showMessageView"
|
||||
:inbox-id="inboxId"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
</section>
|
||||
@@ -25,6 +32,7 @@ import ConversationBox from '../../../components/widgets/conversation/Conversati
|
||||
import PopOverSearch from './search/PopOverSearch';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import wootConstants from 'dashboard/constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -69,6 +77,21 @@ export default {
|
||||
chatList: 'getAllConversations',
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
showConversationList() {
|
||||
return this.isOnExpandedLayout ? !this.conversationId : true;
|
||||
},
|
||||
showMessageView() {
|
||||
return this.conversationId ? true : !this.isOnExpandedLayout;
|
||||
},
|
||||
isOnExpandedLayout() {
|
||||
const {
|
||||
LAYOUT_TYPES: { CONDENSED },
|
||||
} = wootConstants;
|
||||
const {
|
||||
conversation_display_type: conversationDisplayType = CONDENSED,
|
||||
} = this.uiSettings;
|
||||
return conversationDisplayType !== CONDENSED;
|
||||
},
|
||||
isContactPanelOpen() {
|
||||
if (this.currentChat.id) {
|
||||
const {
|
||||
@@ -101,6 +124,17 @@ export default {
|
||||
this.$store.dispatch('setActiveInbox', this.inboxId);
|
||||
this.setActiveChat();
|
||||
},
|
||||
toggleConversationLayout() {
|
||||
const { LAYOUT_TYPES } = wootConstants;
|
||||
const {
|
||||
conversation_display_type: conversationDisplayType = LAYOUT_TYPES.CONDENSED,
|
||||
} = this.uiSettings;
|
||||
const newViewType =
|
||||
conversationDisplayType === LAYOUT_TYPES.CONDENSED
|
||||
? LAYOUT_TYPES.EXPANDED
|
||||
: LAYOUT_TYPES.CONDENSED;
|
||||
this.updateUISettings({ conversation_display_type: newViewType });
|
||||
},
|
||||
fetchConversationIfUnavailable() {
|
||||
if (!this.conversationId) {
|
||||
return;
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
:placeholder="$t('CONVERSATION.SEARCH_MESSAGES')"
|
||||
@focus="onSearch"
|
||||
/>
|
||||
<switch-layout
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@toggle="$emit('toggle-conversation-layout')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showSearchBox" class="results-wrap">
|
||||
<div class="show-results">
|
||||
@@ -55,10 +59,11 @@ import { mapGetters } from 'vuex';
|
||||
import timeMixin from '../../../../mixins/time';
|
||||
import ResultItem from './ResultItem';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
|
||||
import SwitchLayout from './SwitchLayout.vue';
|
||||
export default {
|
||||
components: {
|
||||
ResultItem,
|
||||
SwitchLayout,
|
||||
},
|
||||
|
||||
directives: {
|
||||
@@ -71,6 +76,13 @@ export default {
|
||||
|
||||
mixins: [timeMixin, messageFormatterMixin, clickaway],
|
||||
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchTerm: '',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
|
||||
class="layout-switch__container"
|
||||
@click="toggle"
|
||||
>
|
||||
<div
|
||||
class="layout-switch__btn left"
|
||||
:class="{ active: !isOnExpandedLayout }"
|
||||
>
|
||||
<fluent-icon icon="panel-sidebar" :size="16" />
|
||||
</div>
|
||||
<div
|
||||
class="layout-switch__btn right"
|
||||
:class="{ active: isOnExpandedLayout }"
|
||||
>
|
||||
<fluent-icon icon="panel-contract" :size="16" />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
this.$emit('toggle');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" soped>
|
||||
.layout-switch__container {
|
||||
align-items: center;
|
||||
background-color: var(--s-100);
|
||||
border-radius: var(--border-radius-medium);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: var(--space-micro);
|
||||
|
||||
.layout-switch__btn {
|
||||
border-radius: var(--border-radius-normal);
|
||||
color: var(--s-400);
|
||||
display: flex;
|
||||
padding: var(--space-micro) var(--space-smaller);
|
||||
|
||||
&.active {
|
||||
background-color: var(--white);
|
||||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user