mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-29 12:19:34 +00:00
webmail - bugfixes - refinement - attachment support
This commit is contained in:
parent
2a91b438e4
commit
b3d77c659b
11 changed files with 746 additions and 124 deletions
|
|
@ -1,19 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
Archive, Trash2, Reply, ReplyAll, Forward,
|
||||
MoreVertical, MailOpen, Star,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal,
|
||||
} from 'radix-vue'
|
||||
import {
|
||||
DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuPortal,
|
||||
} from 'radix-vue'
|
||||
import Avatar from '../ui/Avatar.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Separator from '../ui/Separator.vue'
|
||||
import Textarea from '../ui/Textarea.vue'
|
||||
import AttachmentBar from './AttachmentBar.vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { mailApi } from '../../api/mail'
|
||||
|
||||
const props = defineProps({
|
||||
message: { type: Object, default: null }
|
||||
message: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const store = useMailStore()
|
||||
const replyText = ref('')
|
||||
const sendingReply = ref(false)
|
||||
|
||||
const safeHtml = computed(() => {
|
||||
if (!props.message?.html_body) return ''
|
||||
|
|
@ -23,27 +37,50 @@ const safeHtml = computed(() => {
|
|||
const quotedBody = computed(() => {
|
||||
if (!props.message) return ''
|
||||
const from = props.message.from_address || ''
|
||||
const date = props.message.received_at ? format(new Date(props.message.received_at), 'PPpp') : ''
|
||||
const date = props.message.received_at
|
||||
? format(new Date(props.message.received_at), 'PPpp')
|
||||
: ''
|
||||
const original = props.message.html_body || `<pre>${props.message.text_body || ''}</pre>`
|
||||
return `<p></p><blockquote style="border-left:2px solid #ccc;padding-left:1em;color:#555;"><p>On ${date}, ${from} wrote:</p>${original}</blockquote>`
|
||||
return `<br><blockquote style="border-left:2px solid #ccc;padding-left:1em;color:#555;margin:1em 0;"><p>On ${date}, ${from} wrote:</p>${original}</blockquote>`
|
||||
})
|
||||
|
||||
const reply = () => {
|
||||
const replyTo = () => {
|
||||
if (!props.message) return
|
||||
store.composeDefaults = {
|
||||
to: props.message.from_address,
|
||||
subject: props.message.subject?.startsWith('Re:') ? props.message.subject : `Re: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value
|
||||
subject: props.message.subject?.startsWith('Re:')
|
||||
? props.message.subject
|
||||
: `Re: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value,
|
||||
}
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
|
||||
const forward = () => {
|
||||
const replyAll = () => {
|
||||
if (!props.message) return
|
||||
const allAddresses = [
|
||||
props.message.from_address,
|
||||
...(JSON.parse(props.message.to_addresses || '[]')),
|
||||
...(JSON.parse(props.message.cc_addresses || '[]')),
|
||||
].filter((a: string) => a && a !== store.currentMailbox)
|
||||
store.composeDefaults = {
|
||||
to: allAddresses.join(', '),
|
||||
subject: props.message.subject?.startsWith('Re:')
|
||||
? props.message.subject
|
||||
: `Re: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value,
|
||||
}
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
|
||||
const forwardMsg = () => {
|
||||
if (!props.message) return
|
||||
store.composeDefaults = {
|
||||
to: '',
|
||||
subject: props.message.subject?.startsWith('Fwd:') ? props.message.subject : `Fwd: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value
|
||||
subject: props.message.subject?.startsWith('Fwd:')
|
||||
? props.message.subject
|
||||
: `Fwd: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value,
|
||||
}
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
|
|
@ -58,39 +95,202 @@ const trash = async () => {
|
|||
console.error('Failed to trash message', e)
|
||||
}
|
||||
}
|
||||
|
||||
const markUnread = async () => {
|
||||
if (!props.message || !store.currentMailbox) return
|
||||
try {
|
||||
await mailApi.updateMessage(store.currentMailbox, props.message.id, { is_read: false })
|
||||
const idx = store.messages.findIndex((m: any) => m.id === props.message!.id)
|
||||
if (idx !== -1) store.messages[idx] = { ...store.messages[idx], is_read: 0 }
|
||||
store.currentMessage = null
|
||||
} catch (e) {
|
||||
console.error('Failed to mark unread', e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!props.message || !store.currentMailbox) return
|
||||
const newVal = props.message.is_starred ? 0 : 1
|
||||
try {
|
||||
await mailApi.updateMessage(store.currentMailbox, props.message.id, { is_starred: newVal })
|
||||
const idx = store.messages.findIndex((m: any) => m.id === props.message!.id)
|
||||
if (idx !== -1) store.messages[idx] = { ...store.messages[idx], is_starred: newVal }
|
||||
if (store.currentMessage) store.currentMessage = { ...store.currentMessage, is_starred: newVal }
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e)
|
||||
}
|
||||
}
|
||||
|
||||
const sendInlineReply = async () => {
|
||||
if (!props.message || !store.currentMailbox || !replyText.value.trim()) return
|
||||
sendingReply.value = true
|
||||
try {
|
||||
await mailApi.sendMessage(store.currentMailbox, {
|
||||
to: props.message.from_address,
|
||||
subject: props.message.subject?.startsWith('Re:')
|
||||
? props.message.subject
|
||||
: `Re: ${props.message.subject || ''}`,
|
||||
text: replyText.value,
|
||||
html: replyText.value.replace(/\n/g, '<br>'),
|
||||
in_reply_to: props.message.message_id,
|
||||
})
|
||||
replyText.value = ''
|
||||
} catch (e) {
|
||||
console.error('Failed to send reply', e)
|
||||
} finally {
|
||||
sendingReply.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" class="flex h-full flex-col">
|
||||
<div class="flex items-start p-4">
|
||||
<div class="flex items-start gap-4 text-sm">
|
||||
<Avatar :initials="message.from_name?.[0] || message.from_address?.[0] || '?'" />
|
||||
<div class="grid gap-1">
|
||||
<div class="font-semibold">{{ message.from_name }}</div>
|
||||
<div class="line-clamp-1 text-xs">{{ message.subject }}</div>
|
||||
<div class="line-clamp-1 text-xs">
|
||||
<span class="font-medium">From:</span> {{ message.from_address }}
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message" @click="trash">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Move to trash
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
||||
<Separator orientation="vertical" class="mx-1 h-6" />
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message" @click="replyTo">
|
||||
<Reply class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Reply
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message" @click="replyAll">
|
||||
<ReplyAll class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Reply all
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message" @click="forwardMsg">
|
||||
<Forward class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Forward
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" class="mx-2 h-6" />
|
||||
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message">
|
||||
<MoreVertical class="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
class="z-50 min-w-[160px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent"
|
||||
@click="markUnread"
|
||||
>
|
||||
<MailOpen class="mr-2 size-4" />
|
||||
Mark as unread
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent"
|
||||
@click="toggleStar"
|
||||
>
|
||||
<Star class="mr-2 size-4" />
|
||||
{{ message?.is_starred ? 'Unstar' : 'Star' }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<template v-if="message">
|
||||
<div class="flex items-start p-4">
|
||||
<div class="flex items-start gap-4 text-sm">
|
||||
<Avatar :initials="message.from_name?.[0] || message.from_address?.[0] || '?'" />
|
||||
<div class="grid gap-1">
|
||||
<div class="font-semibold">{{ message.from_name || message.from_address }}</div>
|
||||
<div class="line-clamp-1 text-xs">{{ message.subject }}</div>
|
||||
<div class="line-clamp-1 text-xs">
|
||||
<span class="font-medium">Reply-To:</span> {{ message.from_address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.received_at" class="ml-auto text-xs text-muted-foreground">
|
||||
{{ format(new Date(message.received_at), 'PPpp') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.received_at" class="ml-auto text-xs text-muted-foreground">
|
||||
{{ format(new Date(message.received_at), 'PPpp') }}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex-1 overflow-y-auto whitespace-pre-wrap p-4 text-sm">
|
||||
<div v-if="message.html_body" v-html="safeHtml" class="prose max-w-none dark:prose-invert"></div>
|
||||
<div v-else>{{ message.text_body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex-1 overflow-y-auto p-4 text-sm">
|
||||
<div v-if="message.html_body" v-html="safeHtml" class="prose max-w-none dark:prose-invert"></div>
|
||||
<div v-else class="whitespace-pre-wrap">{{ message.text_body }}</div>
|
||||
</div>
|
||||
<AttachmentBar :attachments="message.attachments" />
|
||||
<Separator />
|
||||
<div class="p-4 flex gap-2">
|
||||
<Button @click="reply">Reply</Button>
|
||||
<Button variant="outline" @click="forward">Forward</Button>
|
||||
<Button variant="destructive" @click="trash">Trash</Button>
|
||||
|
||||
<AttachmentBar :attachments="message.attachments" />
|
||||
|
||||
<Separator class="mt-auto" />
|
||||
|
||||
<div class="p-4">
|
||||
<form @submit.prevent="sendInlineReply">
|
||||
<div class="grid gap-4">
|
||||
<Textarea
|
||||
v-model="replyText"
|
||||
class="p-4 min-h-[100px]"
|
||||
:placeholder="`Reply ${message.from_name || message.from_address}...`"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
class="ml-auto"
|
||||
:disabled="sendingReply || !replyText.trim()"
|
||||
>
|
||||
{{ sendingReply ? 'Sending...' : 'Send' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="flex flex-1 items-center justify-center p-8 text-muted-foreground">
|
||||
No message selected
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex h-full items-center justify-center p-8 text-muted-foreground">
|
||||
No message selected
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue