522 lines
13 KiB
Vue
522 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
|
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
|
import StarterKit from '@tiptap/starter-kit'
|
|
import Link from '@tiptap/extension-link'
|
|
import Underline from '@tiptap/extension-underline'
|
|
import TextAlign from '@tiptap/extension-text-align'
|
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
import { entityService } from '@MailManager/services'
|
|
import type { EntityInterface } from '@MailManager/types/entity'
|
|
import type { MessageInterface } from '@MailManager/types/message'
|
|
import type { CollectionInterface } from '@MailManager/types/collection'
|
|
import { MessageObject } from '@MailManager/models/message'
|
|
import { EntityObject } from '@MailManager/models/entity'
|
|
|
|
// Props
|
|
interface Props {
|
|
replyTo?: EntityInterface<MessageInterface> | null
|
|
folder?: CollectionInterface | null
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
// Emits
|
|
const emit = defineEmits<{
|
|
close: []
|
|
sent: []
|
|
}>()
|
|
|
|
// State
|
|
const to = ref<string[]>([])
|
|
const cc = ref<string[]>([])
|
|
const bcc = ref<string[]>([])
|
|
const subject = ref('')
|
|
const showCc = ref(false)
|
|
const showBcc = ref(false)
|
|
const sending = ref(false)
|
|
const saving = ref(false)
|
|
const lastSaved = ref<Date | null>(null)
|
|
const draftId = ref<string | null>(null)
|
|
|
|
// Auto-save timer
|
|
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// Initialize Tiptap editor
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit,
|
|
Link.configure({
|
|
openOnClick: false,
|
|
}),
|
|
Underline,
|
|
TextAlign.configure({
|
|
types: ['heading', 'paragraph'],
|
|
}),
|
|
Placeholder.configure({
|
|
placeholder: 'Write your message...',
|
|
}),
|
|
],
|
|
content: '',
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'tiptap-editor pa-4',
|
|
},
|
|
},
|
|
})
|
|
|
|
// Initialize from reply-to message
|
|
if (props.replyTo) {
|
|
const replyMessage = new MessageObject(props.replyTo.properties)
|
|
|
|
const fromEmail = replyMessage.from?.address
|
|
to.value = fromEmail ? [fromEmail] : []
|
|
|
|
const originalSubject = replyMessage.subject || ''
|
|
subject.value = originalSubject.startsWith('Re:')
|
|
? originalSubject
|
|
: `Re: ${originalSubject}`
|
|
|
|
// Add quoted reply - prefer HTML content, fallback to text
|
|
const originalBody = replyMessage.getHtmlContent() || replyMessage.getTextContent() || ''
|
|
const senderName = replyMessage.from?.label || replyMessage.from?.address || 'Unknown'
|
|
const quotedReply = `<p><br></p><p>On ${new Date(replyMessage.date || '').toLocaleString()}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`
|
|
editor.value?.commands.setContent(quotedReply)
|
|
}
|
|
|
|
// Computed
|
|
const canSend = computed(() => {
|
|
return to.value.length > 0 && subject.value.trim().length > 0
|
|
})
|
|
|
|
const saveStatus = computed(() => {
|
|
if (saving.value) return 'Saving...'
|
|
if (lastSaved.value) {
|
|
const seconds = Math.floor((Date.now() - lastSaved.value.getTime()) / 1000)
|
|
if (seconds < 60) return 'Saved just now'
|
|
if (seconds < 3600) return `Saved ${Math.floor(seconds / 60)} min ago`
|
|
return `Saved at ${lastSaved.value.toLocaleTimeString()}`
|
|
}
|
|
return ''
|
|
})
|
|
|
|
// Auto-save function
|
|
const saveDraft = async () => {
|
|
if (saving.value || sending.value) return
|
|
if (!props.folder) return
|
|
|
|
// Don't save if completely empty
|
|
if (to.value.length === 0 && subject.value.trim().length === 0 && !editor.value?.getText().trim()) {
|
|
return
|
|
}
|
|
|
|
saving.value = true
|
|
|
|
try {
|
|
const draftData = {
|
|
to: to.value,
|
|
cc: cc.value,
|
|
bcc: bcc.value,
|
|
subject: subject.value,
|
|
body: {
|
|
html: editor.value?.getHTML() || '',
|
|
text: editor.value?.getText() || '',
|
|
},
|
|
}
|
|
|
|
// Find drafts folder for this service
|
|
// For now, we'll use the current folder's service
|
|
// In a real implementation, you'd find the actual Drafts folder
|
|
const response = await entityService.create({
|
|
provider: props.folder.provider,
|
|
service: props.folder.service,
|
|
collection: props.folder.identifier, // Should be drafts folder ID
|
|
properties: draftData,
|
|
})
|
|
|
|
if (response) {
|
|
draftId.value = String(response.identifier)
|
|
}
|
|
|
|
lastSaved.value = new Date()
|
|
} catch (error) {
|
|
console.error('[MessageComposer] Failed to save draft:', error)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// Watch for changes and trigger auto-save
|
|
const scheduleAutoSave = () => {
|
|
if (autoSaveTimer) {
|
|
clearTimeout(autoSaveTimer)
|
|
}
|
|
|
|
autoSaveTimer = setTimeout(() => {
|
|
saveDraft()
|
|
}, 30000) // 30 seconds
|
|
}
|
|
|
|
watch([to, cc, bcc, subject], () => {
|
|
scheduleAutoSave()
|
|
}, { deep: true })
|
|
|
|
// Watch editor content changes
|
|
if (editor.value) {
|
|
editor.value.on('update', () => {
|
|
scheduleAutoSave()
|
|
})
|
|
}
|
|
|
|
// Cleanup
|
|
onBeforeUnmount(() => {
|
|
if (autoSaveTimer) {
|
|
clearTimeout(autoSaveTimer)
|
|
}
|
|
editor.value?.destroy()
|
|
})
|
|
|
|
// Handlers
|
|
const handleClose = () => {
|
|
emit('close')
|
|
}
|
|
|
|
const handleSend = async () => {
|
|
if (!canSend.value || sending.value) return
|
|
|
|
sending.value = true
|
|
|
|
try {
|
|
await entityService.transmit({
|
|
message: {
|
|
to: to.value,
|
|
cc: cc.value.length > 0 ? cc.value : undefined,
|
|
bcc: bcc.value.length > 0 ? bcc.value : undefined,
|
|
subject: subject.value,
|
|
body: {
|
|
html: editor.value?.getHTML() || '',
|
|
text: editor.value?.getText() || '',
|
|
},
|
|
},
|
|
})
|
|
|
|
// Delete draft if it was saved
|
|
if (draftId.value && props.folder) {
|
|
try {
|
|
await entityService.delete({
|
|
provider: props.folder.provider,
|
|
service: props.folder.service,
|
|
collection: props.folder.identifier,
|
|
identifier: draftId.value,
|
|
})
|
|
} catch (error) {
|
|
console.error('[MessageComposer] Failed to delete draft:', error)
|
|
}
|
|
}
|
|
|
|
emit('sent')
|
|
} catch (error) {
|
|
console.error('[MessageComposer] Failed to send message:', error)
|
|
alert('Failed to send message. Please try again.')
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
|
|
const toggleCc = () => {
|
|
showCc.value = !showCc.value
|
|
}
|
|
|
|
const toggleBcc = () => {
|
|
showBcc.value = !showBcc.value
|
|
}
|
|
|
|
// Toolbar actions
|
|
const toggleBold = () => editor.value?.chain().focus().toggleBold().run()
|
|
const toggleItalic = () => editor.value?.chain().focus().toggleItalic().run()
|
|
const toggleUnderline = () => editor.value?.chain().focus().toggleUnderline().run()
|
|
const toggleBulletList = () => editor.value?.chain().focus().toggleBulletList().run()
|
|
const toggleOrderedList = () => editor.value?.chain().focus().toggleOrderedList().run()
|
|
const setLink = () => {
|
|
const url = window.prompt('Enter URL:')
|
|
if (url) {
|
|
editor.value?.chain().focus().setLink({ href: url }).run()
|
|
}
|
|
}
|
|
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
|
|
|
const isActive = (name: string, attrs?: any) => {
|
|
return editor.value?.isActive(name, attrs) || false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="message-composer">
|
|
<!-- Toolbar -->
|
|
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
|
|
<v-btn
|
|
variant="text"
|
|
@click="handleClose"
|
|
icon="mdi-close"
|
|
>
|
|
<v-icon>mdi-close</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
|
|
</v-btn>
|
|
|
|
<v-toolbar-title>
|
|
{{ replyTo ? 'Reply' : 'New Message' }}
|
|
</v-toolbar-title>
|
|
|
|
<v-spacer />
|
|
|
|
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
|
|
{{ saveStatus }}
|
|
</span>
|
|
|
|
<v-btn
|
|
color="primary"
|
|
:disabled="!canSend"
|
|
:loading="sending"
|
|
@click="handleSend"
|
|
prepend-icon="mdi-send"
|
|
>
|
|
Send
|
|
</v-btn>
|
|
</v-toolbar>
|
|
|
|
<!-- Composer content -->
|
|
<div class="composer-content">
|
|
<!-- Recipients -->
|
|
<div class="composer-fields pa-4">
|
|
<v-combobox
|
|
v-model="to"
|
|
label="To"
|
|
chips
|
|
multiple
|
|
closable-chips
|
|
variant="outlined"
|
|
density="compact"
|
|
class="mb-2"
|
|
>
|
|
<template v-slot:append-inner>
|
|
<v-btn
|
|
size="x-small"
|
|
variant="text"
|
|
@click="toggleCc"
|
|
class="mr-1"
|
|
>
|
|
Cc
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
variant="text"
|
|
@click="toggleBcc"
|
|
>
|
|
Bcc
|
|
</v-btn>
|
|
</template>
|
|
</v-combobox>
|
|
|
|
<v-combobox
|
|
v-if="showCc"
|
|
v-model="cc"
|
|
label="Cc"
|
|
chips
|
|
multiple
|
|
closable-chips
|
|
variant="outlined"
|
|
density="compact"
|
|
class="mb-2"
|
|
/>
|
|
|
|
<v-combobox
|
|
v-if="showBcc"
|
|
v-model="bcc"
|
|
label="Bcc"
|
|
chips
|
|
multiple
|
|
closable-chips
|
|
variant="outlined"
|
|
density="compact"
|
|
class="mb-2"
|
|
/>
|
|
|
|
<v-text-field
|
|
v-model="subject"
|
|
label="Subject"
|
|
variant="outlined"
|
|
density="compact"
|
|
/>
|
|
</div>
|
|
|
|
<v-divider />
|
|
|
|
<!-- Editor toolbar -->
|
|
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
|
<v-btn
|
|
icon
|
|
size="small"
|
|
:class="{ 'v-btn--active': isActive('bold') }"
|
|
@click="toggleBold"
|
|
>
|
|
<v-icon>mdi-format-bold</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
icon
|
|
size="small"
|
|
:class="{ 'v-btn--active': isActive('italic') }"
|
|
@click="toggleItalic"
|
|
>
|
|
<v-icon>mdi-format-italic</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
icon
|
|
size="small"
|
|
:class="{ 'v-btn--active': isActive('underline') }"
|
|
@click="toggleUnderline"
|
|
>
|
|
<v-icon>mdi-format-underline</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
|
</v-btn>
|
|
|
|
<v-divider vertical class="mx-2" />
|
|
|
|
<v-btn
|
|
icon
|
|
size="small"
|
|
:class="{ 'v-btn--active': isActive('bulletList') }"
|
|
@click="toggleBulletList"
|
|
>
|
|
<v-icon>mdi-format-list-bulleted</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
icon
|
|
size="small"
|
|
:class="{ 'v-btn--active': isActive('orderedList') }"
|
|
@click="toggleOrderedList"
|
|
>
|
|
<v-icon>mdi-format-list-numbered</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
|
</v-btn>
|
|
|
|
<v-divider vertical class="mx-2" />
|
|
|
|
<v-btn
|
|
icon
|
|
size="small"
|
|
:class="{ 'v-btn--active': isActive('link') }"
|
|
@click="isActive('link') ? removeLink() : setLink()"
|
|
>
|
|
<v-icon>mdi-link</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
|
</v-btn>
|
|
|
|
<v-spacer />
|
|
|
|
<v-btn
|
|
icon
|
|
size="small"
|
|
>
|
|
<v-icon>mdi-paperclip</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
|
</v-btn>
|
|
</v-toolbar>
|
|
|
|
<v-divider />
|
|
|
|
<!-- Editor -->
|
|
<div class="editor-container">
|
|
<editor-content :editor="editor" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.message-composer {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.composer-toolbar {
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
}
|
|
|
|
.composer-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.composer-fields {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.editor-toolbar {
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
}
|
|
|
|
.editor-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
background-color: rgb(var(--v-theme-background));
|
|
}
|
|
|
|
.v-btn--active {
|
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
|
color: rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
:deep(.tiptap-editor) {
|
|
outline: none;
|
|
min-height: 300px;
|
|
|
|
p.is-editor-empty:first-child::before {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
content: attr(data-placeholder);
|
|
float: left;
|
|
height: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
h1, h2, h3, h4, h5, h6 {
|
|
line-height: 1.3;
|
|
margin-top: 1em;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
p {
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
ul, ol {
|
|
padding-left: 1.5em;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
blockquote {
|
|
border-left: 3px solid rgb(var(--v-border-color));
|
|
padding-left: 1em;
|
|
margin-left: 0;
|
|
margin-bottom: 0.5em;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
|
|
a {
|
|
color: rgb(var(--v-theme-primary));
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
</style>
|