Skip to content

Commit f1ede15

Browse files
authored
Merge pull request #261 from shariquerik/email-reply-collapser
feat: Email Reply Collapser
2 parents 02b36e9 + ab05e93 commit f1ede15

File tree

5 files changed

+181
-14
lines changed

5 files changed

+181
-14
lines changed

frontend/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"serve": "vite preview"
1010
},
1111
"dependencies": {
12+
"@tiptap/extension-paragraph": "^2.4.0",
1213
"@twilio/voice-sdk": "^2.10.2",
1314
"@vueuse/core": "^10.3.0",
1415
"@vueuse/integrations": "^10.3.0",

frontend/src/components/Activities.vue

+13-7
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@
442442
'outgoing_call',
443443
].includes(activity.activity_type),
444444
'bg-white': ['added', 'removed', 'changed'].includes(
445-
activity.activity_type
445+
activity.activity_type,
446446
),
447447
}"
448448
>
@@ -528,7 +528,10 @@
528528
<span v-if="activity.data.bcc">{{ activity.data.bcc }}</span>
529529
</div>
530530
<EmailContent :content="activity.data.content" />
531-
<div class="flex flex-wrap gap-2">
531+
<div
532+
v-if="activity.data?.attachments?.length"
533+
class="flex flex-wrap gap-2"
534+
>
532535
<AttachmentItem
533536
v-for="a in activity.data.attachments"
534537
:key="a.file_url"
@@ -1102,7 +1105,7 @@ const defaultActions = computed(() => {
11021105
},
11031106
]
11041107
return actions.filter((action) =>
1105-
action.condition ? action.condition() : true
1108+
action.condition ? action.condition() : true,
11061109
)
11071110
})
11081111
@@ -1120,12 +1123,12 @@ const activities = computed(() => {
11201123
} else if (props.title == 'Emails') {
11211124
if (!all_activities.data?.versions) return []
11221125
activities = all_activities.data.versions.filter(
1123-
(activity) => activity.activity_type === 'communication'
1126+
(activity) => activity.activity_type === 'communication',
11241127
)
11251128
} else if (props.title == 'Comments') {
11261129
if (!all_activities.data?.versions) return []
11271130
activities = all_activities.data.versions.filter(
1128-
(activity) => activity.activity_type === 'comment'
1131+
(activity) => activity.activity_type === 'comment',
11291132
)
11301133
} else if (props.title == 'Calls') {
11311134
if (!all_activities.data?.calls) return []
@@ -1338,12 +1341,15 @@ function reply(email, reply_all = false) {
13381341
editor.bccEmails = bcc
13391342
}
13401343
1344+
let repliedMessage = `<blockquote>${message}</blockquote>`
1345+
13411346
editor.editor
13421347
.chain()
13431348
.clearContent()
1344-
.insertContent(message)
1349+
.insertContent('<p>.</p>')
1350+
.updateAttributes('paragraph', {class:'reply-to-content'})
1351+
.insertContent(repliedMessage)
13451352
.focus('all')
1346-
.setBlockquote()
13471353
.insertContentAt(0, { type: 'paragraph' })
13481354
.focus('start')
13491355
.run()

frontend/src/components/EmailContent.vue

+127-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
style="
77
mask-image: linear-gradient(
88
to bottom,
9-
black calc(100% - 30px),
9+
black calc(100% - 20px),
1010
transparent 100%
1111
);
1212
"
@@ -27,6 +27,90 @@ const files = import.meta.globEager('/src/index.css', { query: '?inline' })
2727
const css = files['/src/index.css'].default
2828
2929
const iframeRef = ref(null)
30+
const _content = ref(props.content)
31+
32+
const parser = new DOMParser()
33+
const doc = parser.parseFromString(_content.value, 'text/html')
34+
35+
const gmailReplyToContent = doc.querySelectorAll('div.gmail_quote')
36+
const outlookReplyToContent = doc.querySelectorAll('div#appendonsend')
37+
const replyToContent = doc.querySelectorAll('p.reply-to-content')
38+
39+
if (gmailReplyToContent.length) {
40+
_content.value = parseReplyToContent(doc, 'div.gmail_quote', true)
41+
} else if (outlookReplyToContent.length) {
42+
_content.value = parseReplyToContent(doc, 'div#appendonsend')
43+
} else if (replyToContent.length) {
44+
_content.value = parseReplyToContent(doc, 'p.reply-to-content')
45+
}
46+
47+
function parseReplyToContent(doc, selector, forGmail = false) {
48+
function handleAllInstances(doc) {
49+
const replyToContentElements = doc.querySelectorAll(selector)
50+
if (replyToContentElements.length === 0) return
51+
const replyToContentElement = replyToContentElements[0]
52+
replaceReplyToContent(replyToContentElement, forGmail)
53+
handleAllInstances(doc)
54+
}
55+
56+
handleAllInstances(doc)
57+
58+
return doc.body.innerHTML
59+
}
60+
61+
function replaceReplyToContent(replyToContentElement, forGmail) {
62+
if (!replyToContentElement) return
63+
let randomId = Math.random().toString(36).substring(2, 7)
64+
const wrapper = doc.createElement('div')
65+
wrapper.classList.add('replied-content')
66+
67+
const collapseLabel = doc.createElement('label')
68+
collapseLabel.classList.add('collapse')
69+
collapseLabel.setAttribute('for', randomId)
70+
collapseLabel.innerHTML = '...'
71+
wrapper.appendChild(collapseLabel)
72+
73+
const collapseInput = doc.createElement('input')
74+
collapseInput.setAttribute('id', randomId)
75+
collapseInput.setAttribute('class', 'replyCollapser')
76+
collapseInput.setAttribute('type', 'checkbox')
77+
wrapper.appendChild(collapseInput)
78+
79+
if (forGmail) {
80+
const prevSibling = replyToContentElement.previousElementSibling
81+
if (prevSibling && prevSibling.tagName === 'BR') {
82+
prevSibling.remove()
83+
}
84+
let cloned = replyToContentElement.cloneNode(true)
85+
cloned.classList.remove('gmail_quote')
86+
wrapper.appendChild(cloned)
87+
} else {
88+
const allSiblings = Array.from(replyToContentElement.parentElement.children)
89+
const replyToContentIndex = allSiblings.indexOf(replyToContentElement)
90+
const followingSiblings = allSiblings.slice(replyToContentIndex + 1)
91+
92+
if (followingSiblings.length === 0) return
93+
94+
let clonedFollowingSiblings = followingSiblings.map((sibling) =>
95+
sibling.cloneNode(true),
96+
)
97+
98+
const div = doc.createElement('div')
99+
div.append(...clonedFollowingSiblings)
100+
101+
wrapper.append(div)
102+
103+
// Remove all siblings after the reply-to-content element
104+
for (let i = replyToContentIndex + 1; i < allSiblings.length; i++) {
105+
replyToContentElement.parentElement.removeChild(allSiblings[i])
106+
}
107+
}
108+
109+
replyToContentElement.parentElement.replaceChild(
110+
wrapper,
111+
replyToContentElement,
112+
)
113+
}
30114
31115
const htmlContent = `
32116
<!DOCTYPE html>
@@ -35,6 +119,35 @@ const htmlContent = `
35119
<style>
36120
${css}
37121
122+
.replied-content .collapse {
123+
margin: 10px 0 10px 0;
124+
visibility: visible;
125+
cursor: pointer;
126+
display: flex;
127+
font-size: larger;
128+
font-weight: 700;
129+
height: 12px;
130+
line-height: 0.1;
131+
background: #e8eaed;
132+
width: 23px;
133+
justify-content: center;
134+
border-radius: 5px;
135+
}
136+
137+
.replied-content .collapse:hover {
138+
background: #dadce0;
139+
}
140+
141+
.replied-content .collapse + input {
142+
display: none;
143+
}
144+
.replied-content .collapse + input + div {
145+
display: none;
146+
}
147+
.replied-content .collapse + input:checked + div {
148+
display: block;
149+
}
150+
38151
.email-content {
39152
word-break: break-word;
40153
}
@@ -110,7 +223,7 @@ const htmlContent = `
110223
</style>
111224
</head>
112225
<body>
113-
<div ref="emailContentRef" class="email-content prose-f">${props.content}</div>
226+
<div ref="emailContentRef" class="email-content prose-f">${_content.value}</div>
114227
</body>
115228
</html>
116229
`
@@ -120,7 +233,18 @@ watch(iframeRef, (iframe) => {
120233
iframe.onload = () => {
121234
const emailContent =
122235
iframe.contentWindow.document.querySelector('.email-content')
123-
iframe.style.height = emailContent.offsetHeight + 25 + 'px'
236+
let parent = emailContent.closest('html')
237+
238+
iframe.style.height = parent.offsetHeight + 'px'
239+
240+
let replyCollapsers = emailContent.querySelectorAll('.replyCollapser')
241+
if (replyCollapsers.length) {
242+
replyCollapsers.forEach((replyCollapser) => {
243+
replyCollapser.addEventListener('change', () => {
244+
iframe.style.height = parent.offsetHeight + 'px'
245+
})
246+
})
247+
}
124248
}
125249
}
126250
})

frontend/src/components/EmailEditor.vue

+35-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
<template>
22
<TextEditor
33
ref="textEditor"
4-
:editor-class="['prose-sm max-w-none', editable && 'min-h-[7rem]']"
4+
:editor-class="[
5+
'prose-sm max-w-none',
6+
editable && 'min-h-[7rem]',
7+
'[&_p.reply-to-content]:hidden',
8+
]"
59
:content="content"
610
@change="editable ? (content = $event) : null"
7-
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
11+
:starterkit-options="{
12+
heading: { levels: [2, 3, 4, 5, 6] },
13+
paragraph: false,
14+
}"
815
:placeholder="placeholder"
916
:editable="editable"
17+
:extensions="[CustomParagraph]"
1018
>
1119
<template #top>
1220
<div class="flex flex-col gap-3">
@@ -25,13 +33,17 @@
2533
:label="__('CC')"
2634
variant="ghost"
2735
@click="toggleCC()"
28-
:class="[cc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500']"
36+
:class="[
37+
cc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
38+
]"
2939
/>
3040
<Button
3141
:label="__('BCC')"
3242
variant="ghost"
3343
@click="toggleBCC()"
34-
:class="[bcc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500']"
44+
:class="[
45+
bcc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
46+
]"
3547
/>
3648
</div>
3749
</div>
@@ -164,6 +176,7 @@ import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
164176
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
165177
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
166178
import { validateEmail } from '@/utils'
179+
import Paragraph from '@tiptap/extension-paragraph'
167180
import { EditorContent } from '@tiptap/vue-3'
168181
import { ref, computed, defineModel, nextTick } from 'vue'
169182
@@ -198,6 +211,24 @@ const props = defineProps({
198211
},
199212
})
200213
214+
const CustomParagraph = Paragraph.extend({
215+
addAttributes() {
216+
return {
217+
class: {
218+
default: null,
219+
renderHTML: (attributes) => {
220+
if (!attributes.class) {
221+
return {}
222+
}
223+
return {
224+
class: `${attributes.class}`,
225+
}
226+
},
227+
},
228+
}
229+
},
230+
})
231+
201232
const modelValue = defineModel()
202233
const attachments = defineModel('attachments')
203234
const content = defineModel('content')

yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -1808,6 +1808,11 @@
18081808
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.2.6.tgz#0e66d2ce21116e43fd006961c42f187ee5e5beab"
18091809
integrity sha512-M2rM3pfzziUb7xS9x2dANCokO89okbqg5IqU4VPkZhk0Mfq9czyCatt58TYkAsE3ccsGhdTYtFBTDeKBtsHUqg==
18101810

1811+
"@tiptap/extension-paragraph@^2.4.0":
1812+
version "2.4.0"
1813+
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.4.0.tgz#5b9aea8775937b327bbe6754be12ae3144fb09ff"
1814+
integrity sha512-+yse0Ow67IRwcACd9K/CzBcxlpr9OFnmf0x9uqpaWt1eHck1sJnti6jrw5DVVkyEBHDh/cnkkV49gvctT/NyCw==
1815+
18111816
"@tiptap/extension-placeholder@^2.0.3":
18121817
version "2.2.6"
18131818
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.2.6.tgz#7cb63e398a5301d1e132d4145daef3acb87e7dc5"

0 commit comments

Comments
 (0)