Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Email Reply Collapser #261

Merged
merged 7 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"serve": "vite preview"
},
"dependencies": {
"@tiptap/extension-paragraph": "^2.4.0",
"@twilio/voice-sdk": "^2.10.2",
"@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0",
Expand Down
20 changes: 13 additions & 7 deletions frontend/src/components/Activities.vue
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@
'outgoing_call',
].includes(activity.activity_type),
'bg-white': ['added', 'removed', 'changed'].includes(
activity.activity_type
activity.activity_type,
),
}"
>
Expand Down Expand Up @@ -528,7 +528,10 @@
<span v-if="activity.data.bcc">{{ activity.data.bcc }}</span>
</div>
<EmailContent :content="activity.data.content" />
<div class="flex flex-wrap gap-2">
<div
v-if="activity.data?.attachments?.length"
class="flex flex-wrap gap-2"
>
<AttachmentItem
v-for="a in activity.data.attachments"
:key="a.file_url"
Expand Down Expand Up @@ -1102,7 +1105,7 @@ const defaultActions = computed(() => {
},
]
return actions.filter((action) =>
action.condition ? action.condition() : true
action.condition ? action.condition() : true,
)
})

Expand All @@ -1120,12 +1123,12 @@ const activities = computed(() => {
} else if (props.title == 'Emails') {
if (!all_activities.data?.versions) return []
activities = all_activities.data.versions.filter(
(activity) => activity.activity_type === 'communication'
(activity) => activity.activity_type === 'communication',
)
} else if (props.title == 'Comments') {
if (!all_activities.data?.versions) return []
activities = all_activities.data.versions.filter(
(activity) => activity.activity_type === 'comment'
(activity) => activity.activity_type === 'comment',
)
} else if (props.title == 'Calls') {
if (!all_activities.data?.calls) return []
Expand Down Expand Up @@ -1338,12 +1341,15 @@ function reply(email, reply_all = false) {
editor.bccEmails = bcc
}

let repliedMessage = `<blockquote>${message}</blockquote>`

editor.editor
.chain()
.clearContent()
.insertContent(message)
.insertContent('<p>.</p>')
.updateAttributes('paragraph', {class:'reply-to-content'})
.insertContent(repliedMessage)
.focus('all')
.setBlockquote()
.insertContentAt(0, { type: 'paragraph' })
.focus('start')
.run()
Expand Down
130 changes: 127 additions & 3 deletions frontend/src/components/EmailContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
style="
mask-image: linear-gradient(
to bottom,
black calc(100% - 30px),
black calc(100% - 20px),
transparent 100%
);
"
Expand All @@ -27,6 +27,90 @@ const files = import.meta.globEager('/src/index.css', { query: '?inline' })
const css = files['/src/index.css'].default

const iframeRef = ref(null)
const _content = ref(props.content)

const parser = new DOMParser()
const doc = parser.parseFromString(_content.value, 'text/html')

const gmailReplyToContent = doc.querySelectorAll('div.gmail_quote')
const outlookReplyToContent = doc.querySelectorAll('div#appendonsend')
const replyToContent = doc.querySelectorAll('p.reply-to-content')

if (gmailReplyToContent.length) {
_content.value = parseReplyToContent(doc, 'div.gmail_quote', true)
} else if (outlookReplyToContent.length) {
_content.value = parseReplyToContent(doc, 'div#appendonsend')
} else if (replyToContent.length) {
_content.value = parseReplyToContent(doc, 'p.reply-to-content')
}

function parseReplyToContent(doc, selector, forGmail = false) {
function handleAllInstances(doc) {
const replyToContentElements = doc.querySelectorAll(selector)
if (replyToContentElements.length === 0) return
const replyToContentElement = replyToContentElements[0]
replaceReplyToContent(replyToContentElement, forGmail)
handleAllInstances(doc)
}

handleAllInstances(doc)

return doc.body.innerHTML
}

function replaceReplyToContent(replyToContentElement, forGmail) {
if (!replyToContentElement) return
let randomId = Math.random().toString(36).substring(2, 7)
const wrapper = doc.createElement('div')
wrapper.classList.add('replied-content')

const collapseLabel = doc.createElement('label')
collapseLabel.classList.add('collapse')
collapseLabel.setAttribute('for', randomId)
collapseLabel.innerHTML = '...'
wrapper.appendChild(collapseLabel)

const collapseInput = doc.createElement('input')
collapseInput.setAttribute('id', randomId)
collapseInput.setAttribute('class', 'replyCollapser')
collapseInput.setAttribute('type', 'checkbox')
wrapper.appendChild(collapseInput)

if (forGmail) {
const prevSibling = replyToContentElement.previousElementSibling
if (prevSibling && prevSibling.tagName === 'BR') {
prevSibling.remove()
}
let cloned = replyToContentElement.cloneNode(true)
cloned.classList.remove('gmail_quote')
wrapper.appendChild(cloned)
} else {
const allSiblings = Array.from(replyToContentElement.parentElement.children)
const replyToContentIndex = allSiblings.indexOf(replyToContentElement)
const followingSiblings = allSiblings.slice(replyToContentIndex + 1)

if (followingSiblings.length === 0) return

let clonedFollowingSiblings = followingSiblings.map((sibling) =>
sibling.cloneNode(true),
)

const div = doc.createElement('div')
div.append(...clonedFollowingSiblings)

wrapper.append(div)

// Remove all siblings after the reply-to-content element
for (let i = replyToContentIndex + 1; i < allSiblings.length; i++) {
replyToContentElement.parentElement.removeChild(allSiblings[i])
}
}

replyToContentElement.parentElement.replaceChild(
wrapper,
replyToContentElement,
)
}

const htmlContent = `
<!DOCTYPE html>
Expand All @@ -35,6 +119,35 @@ const htmlContent = `
<style>
${css}

.replied-content .collapse {
margin: 10px 0 10px 0;
visibility: visible;
cursor: pointer;
display: flex;
font-size: larger;
font-weight: 700;
height: 12px;
line-height: 0.1;
background: #e8eaed;
width: 23px;
justify-content: center;
border-radius: 5px;
}

.replied-content .collapse:hover {
background: #dadce0;
}

.replied-content .collapse + input {
display: none;
}
.replied-content .collapse + input + div {
display: none;
}
.replied-content .collapse + input:checked + div {
display: block;
}

.email-content {
word-break: break-word;
}
Expand Down Expand Up @@ -110,7 +223,7 @@ const htmlContent = `
</style>
</head>
<body>
<div ref="emailContentRef" class="email-content prose-f">${props.content}</div>
<div ref="emailContentRef" class="email-content prose-f">${_content.value}</div>
</body>
</html>
`
Expand All @@ -120,7 +233,18 @@ watch(iframeRef, (iframe) => {
iframe.onload = () => {
const emailContent =
iframe.contentWindow.document.querySelector('.email-content')
iframe.style.height = emailContent.offsetHeight + 25 + 'px'
let parent = emailContent.closest('html')

iframe.style.height = parent.offsetHeight + 'px'

let replyCollapsers = emailContent.querySelectorAll('.replyCollapser')
if (replyCollapsers.length) {
replyCollapsers.forEach((replyCollapser) => {
replyCollapser.addEventListener('change', () => {
iframe.style.height = parent.offsetHeight + 'px'
})
})
}
}
}
})
Expand Down
39 changes: 35 additions & 4 deletions frontend/src/components/EmailEditor.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
<template>
<TextEditor
ref="textEditor"
:editor-class="['prose-sm max-w-none', editable && 'min-h-[7rem]']"
:editor-class="[
'prose-sm max-w-none',
editable && 'min-h-[7rem]',
'[&_p.reply-to-content]:hidden',
]"
:content="content"
@change="editable ? (content = $event) : null"
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
:starterkit-options="{
heading: { levels: [2, 3, 4, 5, 6] },
paragraph: false,
}"
:placeholder="placeholder"
:editable="editable"
:extensions="[CustomParagraph]"
>
<template #top>
<div class="flex flex-col gap-3">
Expand All @@ -25,13 +33,17 @@
:label="__('CC')"
variant="ghost"
@click="toggleCC()"
:class="[cc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500']"
:class="[
cc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
]"
/>
<Button
:label="__('BCC')"
variant="ghost"
@click="toggleBCC()"
:class="[bcc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500']"
:class="[
bcc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
]"
/>
</div>
</div>
Expand Down Expand Up @@ -164,6 +176,7 @@ import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
import { validateEmail } from '@/utils'
import Paragraph from '@tiptap/extension-paragraph'
import { EditorContent } from '@tiptap/vue-3'
import { ref, computed, defineModel, nextTick } from 'vue'

Expand Down Expand Up @@ -198,6 +211,24 @@ const props = defineProps({
},
})

const CustomParagraph = Paragraph.extend({
addAttributes() {
return {
class: {
default: null,
renderHTML: (attributes) => {
if (!attributes.class) {
return {}
}
return {
class: `${attributes.class}`,
}
},
},
}
},
})

const modelValue = defineModel()
const attachments = defineModel('attachments')
const content = defineModel('content')
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1808,6 +1808,11 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.2.6.tgz#0e66d2ce21116e43fd006961c42f187ee5e5beab"
integrity sha512-M2rM3pfzziUb7xS9x2dANCokO89okbqg5IqU4VPkZhk0Mfq9czyCatt58TYkAsE3ccsGhdTYtFBTDeKBtsHUqg==

"@tiptap/extension-paragraph@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.4.0.tgz#5b9aea8775937b327bbe6754be12ae3144fb09ff"
integrity sha512-+yse0Ow67IRwcACd9K/CzBcxlpr9OFnmf0x9uqpaWt1eHck1sJnti6jrw5DVVkyEBHDh/cnkkV49gvctT/NyCw==

"@tiptap/extension-placeholder@^2.0.3":
version "2.2.6"
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.2.6.tgz#7cb63e398a5301d1e132d4145daef3acb87e7dc5"
Expand Down
Loading