Files
nextcloud-mail/src/components/Envelope.vue
2025-07-18 11:33:40 +02:00

1231 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<EnvelopeSkeleton v-draggable-envelope="{
accountId: data.accountId ? data.accountId : mailbox.accountId,
mailboxId: data.mailboxId,
databaseId: data.databaseId,
draggableLabel,
selectedEnvelopes,
isDraggable,
}"
class="list-item-style envelope"
:class="{seen: data.flags.seen, draft, selected: selected}"
:to="link"
:exact="true"
:data-envelope-id="data.databaseId"
:name="addresses"
:details="formatted()"
:one-line="oneLineLayout"
@click.exact="onClick"
@click.ctrl.exact.prevent="toggleSelected"
@click.shift.exact.prevent="onSelectMultiple"
@update:menuOpen="closeMoreAndSnoozeOptions">
<template #icon>
<Star v-if="data.flags.flagged"
fill-color="#f9cf3d"
:size="18"
class="app-content-list-item-star favorite-icon-style"
:class="{ 'one-line': oneLineLayout, 'favorite-icon-style': !oneLineLayout }"
:data-starred="data.flags.flagged ? 'true' : 'false'"
@click.prevent="hasWriteAcl ? onToggleFlagged() : false" />
<ImportantIcon v-if="isImportant"
:size="18"
class="app-content-list-item-star icon-important"
:class="{ 'important-one-line': oneLineLayout, 'icon-important': !oneLineLayout }"
data-starred="true"
@click.prevent="hasWriteAcl ? onToggleImportant() : false" />
<JunkIcon v-if="data.flags.$junk"
:size="18"
class="app-content-list-item-star junk-icon-style"
:class="{ 'one-line': oneLineLayout, 'junk-icon-style': !oneLineLayout }"
:data-starred="data.flags.$junk ? 'true' : 'false'"
@click.prevent="hasWriteAcl ? onToggleJunk() : false" />
<div class="hovering-status"
:class="{ 'hover-active': hoveringAvatar && !selected }"
@mouseenter="hoveringAvatar = true"
@mouseleave="hoveringAvatar = false"
@click.stop.exact.prevent="toggleSelected"
@click.shift.exact.prevent="onSelectMultiple">
<template v-if="hoveringAvatar || selected">
<CheckIcon :size="28" class="check-icon" :class="{ 'app-content-list-item-avatar-selected': selected }" />
</template>
<template v-else>
<Avatar :display-name="addresses"
:email="avatarEmail"
:fetch-avatar="data.fetchAvatarFromClient"
:avatar="data.avatar" />
</template>
</div>
</template>
<template #subname>
<div class="line-two"
:class="{ 'one-line': oneLineLayout }">
<div class="envelope__subtitle">
<Reply v-if="data.flags.answered"
class="seen-icon-style"
:size="18" />
<IconAttachment v-if="data.flags.hasAttachments === true"
class="attachment-icon-style"
:size="18" />
<span class="envelope__subtitle__subject"
:class="{'one-line': oneLineLayout }"
dir="auto">
<span class="envelope__subtitle__subject__text" :class="{'one-line': oneLineLayout, draft }" v-html="subjectForSubtitle" />
</span>
</div>
<div v-if="data.encrypted || data.previewText"
class="envelope__preview-text"
:title="data.summary ? t('mail', 'This summary was AI generated') : null">
<SparkleIcon v-if="data.summary" :size="15" />
{{ isEncrypted ? t('mail', 'Encrypted message') : data.summary ? data.summary.trim() : data.previewText.trim() }}
</div>
</div>
</template>
<template #indicator>
<!-- Color dot -->
<IconBullet v-if="!data.flags.seen"
:size="16"
:aria-hidden="false"
:aria-label="t('mail', 'This message is unread')"
fill-color="var(--color-primary-element)" />
</template>
<template #actions>
<EnvelopePrimaryActions v-if="!moreActionsOpen && !snoozeOptions">
<ActionButton v-if="hasWriteAcl"
class="action--primary"
:close-after-click="true"
@click.prevent="onToggleFlagged">
<template #icon>
<StarOutline v-if="showFavoriteIconVariant"
:size="24" />
<Star v-else
:size="24" />
</template>
{{
data.flags.flagged ? t('mail', 'Unfavorite') : t('mail', 'Favorite')
}}
</ActionButton>
<ActionButton v-if="hasSeenAcl"
class="action--primary"
:close-after-click="true"
@click.prevent="onToggleSeen">
<template #icon>
<EmailUnread v-if="showImportantIconVariant"
:size="24" />
<EmailRead v-else
:size="24" />
</template>
{{
data.flags.seen ? t('mail', 'Unread') : t('mail', 'Read')
}}
</ActionButton>
<ActionButton v-if="hasWriteAcl"
class="action--primary"
:close-after-click="true"
@click.prevent="onToggleImportant">
<template #icon>
<ImportantIcon v-if="isImportant" :size="24" />
<ImportantOutlineIcon v-else :size="24" />
</template>
{{
isImportant ? t('mail', 'Unimportant') : t('mail', 'Important')
}}
</ActionButton>
</EnvelopePrimaryActions>
<template v-if="!moreActionsOpen && !snoozeOptions">
<ActionText>
<template #icon>
<ClockOutlineIcon :size="16" />
</template>
{{
messageLongDate
}}
</ActionText>
<NcActionSeparator />
<ActionButton v-if="hasWriteAcl"
:close-after-click="true"
@click.prevent="onToggleJunk">
<template #icon>
<AlertOctagonIcon :size="16" />
</template>
{{
data.flags.$junk ? t('mail', 'Mark not spam') : t('mail', 'Mark as spam')
}}
</ActionButton>
<ActionButton v-if="hasWriteAcl"
:close-after-click="true"
@click.prevent="onOpenTagModal">
<template #icon>
<TagIcon :size="16" />
</template>
{{ t('mail', 'Edit tags') }}
</ActionButton>
<ActionButton v-if="!isSnoozeDisabled && !isSnoozedMailbox"
:close-after-click="false"
@click="showSnoozeOptions">
<template #icon>
<AlarmIcon :title="t('mail', 'Snooze')"
:size="16" />
</template>
{{
t('mail', 'Snooze')
}}
</ActionButton>
<ActionButton v-if="!isSnoozeDisabled && isSnoozedMailbox"
:close-after-click="true"
@click="onUnSnooze">
<template #icon>
<AlarmIcon :title="t('mail', 'Unsnooze')"
:size="16" />
</template>
{{ t('mail', 'Unsnooze') }}
</ActionButton>
<ActionButton v-if="hasDeleteAcl"
:close-after-click="true"
@click.prevent="onOpenMoveModal">
<template #icon>
<OpenInNewIcon :size="16" />
</template>
<template v-if="layoutMessageViewThreaded">
{{ t('mail', 'Move thread') }}
</template>
<template v-else>
{{ t('mail', 'Move Message') }}
</template>
</ActionButton>
<ActionButton v-if="showArchiveButton && hasArchiveAcl"
:close-after-click="true"
:disabled="disableArchiveButton"
@click.prevent="onArchive">
<template #icon>
<ArchiveIcon :size="16" />
</template>
<template v-if="layoutMessageViewThreaded">
{{ t('mail', 'Archive thread') }}
</template>
<template v-else>
{{ t('mail', 'Archive message') }}
</template>
</ActionButton>
<ActionButton v-if="hasDeleteAcl"
:close-after-click="true"
@click.prevent="onDelete">
<template #icon>
<DeleteIcon :size="16" />
</template>
<template v-if="layoutMessageViewThreaded">
{{ t('mail', 'Delete thread') }}
</template>
<template v-else>
{{ t('mail', 'Delete message') }}
</template>
</ActionButton>
<ActionButton :close-after-click="false"
@click="showMoreActionOptions">
<template #icon>
<DotsHorizontalIcon :size="16" />
</template>
{{ t('mail', 'More actions') }}
</ActionButton>
</template>
<template v-if="snoozeOptions">
<ActionButton :close-after-click="false"
@click="snoozeOptions = false">
<template #icon>
<ChevronLeft :size="16" />
</template>
{{
t('mail', 'Back')
}}
</ActionButton>
<NcActionSeparator />
<ActionButton v-for="option in reminderOptions"
:key="option.key"
:aria-label="option.ariaLabel"
close-after-click
@click.stop="onSnooze(option.timestamp)">
{{ option.label }}
</ActionButton>
<NcActionSeparator />
<NcActionInput type="datetime-local"
is-native-picker
:value="customSnoozeDateTime"
:min="new Date()"
@change="setCustomSnoozeDateTime">
<template #icon>
<CalendarClock :size="16" />
</template>
</NcActionInput>
<ActionButton :aria-label="t('mail', 'Set custom snooze')"
close-after-click
@click.stop="setCustomSnooze(customSnoozeDateTime)">
<template #icon>
<CheckIcon :size="16" />
</template>
{{ t('mail', 'Set custom snooze') }}
</ActionButton>
</template>
<template v-if="moreActionsOpen">
<ActionButton :close-after-click="false"
@click="moreActionsOpen=false">
<template #icon>
<ChevronLeft :size="16" />
</template>
{{ t('mail', 'More actions') }}
</ActionButton>
<ActionButton :close-after-click="true"
@click.prevent="onOpenEditAsNew">
<template #icon>
<PlusIcon :size="16" />
</template>
{{ t('mail', 'Edit as new message') }}
</ActionButton>
<ActionButton :close-after-click="true"
@click.prevent="showEventModal = true">
<template #icon>
<IconCreateEvent :size="16" />
</template>
{{ t('mail', 'Create event') }}
</ActionButton>
<ActionButton :close-after-click="true"
@click.prevent="showTaskModal = true">
<template #icon>
<TaskIcon :size="16" />
</template>
{{ t('mail', 'Create task') }}
</ActionButton>
<ActionLink :close-after-click="true"
:href="exportMessageLink">
<template #icon>
<DownloadIcon :size="16" />
</template>
{{ t('mail', 'Download message') }}
</ActionLink>
</template>
</template>
<template #tags>
<div v-for="tag in tags"
:key="tag.id"
class="tag-group">
<div class="tag-group__bg"
:style="{'background-color': tag.color}" />
<span class="tag-group__label"
:style="{color: tag.color}">
{{ translateTagDisplayName(tag) }}
</span>
</div>
<MoveModal v-if="showMoveModal"
:account="account"
:envelopes="[data]"
:move-thread="listViewThreaded"
@move="onMove"
@close="onCloseMoveModal" />
<EventModal v-if="showEventModal"
:envelope="data"
@close="showEventModal = false" />
<TaskModal v-if="showTaskModal"
:envelope="data"
@close="showTaskModal = false" />
<TagModal v-if="showTagModal"
:account="account"
:envelopes="[data]"
@close="onCloseTagModal" />
</template>
</EnvelopeSkeleton>
</template>
<script>
import {
NcActionButton as ActionButton,
NcActionLink as ActionLink,
NcActionSeparator,
NcActionInput,
NcActionText as ActionText,
} from '@nextcloud/vue'
import EnvelopeSkeleton from './EnvelopeSkeleton.vue'
import AlertOctagonIcon from 'vue-material-design-icons/AlertOctagonOutline.vue'
import Avatar from './Avatar.vue'
import IconCreateEvent from 'vue-material-design-icons/CalendarOutline.vue'
import SparkleIcon from 'vue-material-design-icons/CreationOutline.vue'
import ClockOutlineIcon from 'vue-material-design-icons/ClockOutline.vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'
import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
import DeleteIcon from 'vue-material-design-icons/DeleteOutline.vue'
import ArchiveIcon from 'vue-material-design-icons/ArchiveArrowDownOutline.vue'
import TaskIcon from 'vue-material-design-icons/CheckboxMarkedCirclePlusOutline.vue'
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
import ImportantIcon from 'vue-material-design-icons/LabelVariant.vue'
import ImportantOutlineIcon from 'vue-material-design-icons/LabelVariantOutline.vue'
import { DraggableEnvelopeDirective } from '../directives/drag-and-drop/draggable-envelope/index.js'
import { buildRecipients as buildReplyRecipients } from '../ReplyBuilder.js'
import { shortRelativeDatetime, messageDateTime } from '../util/shortRelativeDatetime.js'
import { showError, showSuccess } from '@nextcloud/dialogs'
import NoTrashMailboxConfiguredError
from '../errors/NoTrashMailboxConfiguredError.js'
import logger from '../logger.js'
import { matchError } from '../errors/match.js'
import MoveModal from './MoveModal.vue'
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
import StarOutline from 'vue-material-design-icons/StarOutline.vue'
import Star from 'vue-material-design-icons/Star.vue'
import Reply from 'vue-material-design-icons/ReplyOutline.vue'
import EmailRead from 'vue-material-design-icons/EmailOpenOutline.vue'
import EmailUnread from 'vue-material-design-icons/EmailOutline.vue'
import IconAttachment from 'vue-material-design-icons/Paperclip.vue'
import IconBullet from 'vue-material-design-icons/CheckboxBlankCircle.vue'
import JunkIcon from './icons/JunkIcon.vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import TagIcon from 'vue-material-design-icons/TagOutline.vue'
import TagModal from './TagModal.vue'
import EventModal from './EventModal.vue'
import TaskModal from './TaskModal.vue'
import EnvelopePrimaryActions from './EnvelopePrimaryActions.vue'
import escapeHtml from 'escape-html'
import { hiddenTags } from './tags.js'
import { generateUrl } from '@nextcloud/router'
import { isPgpText } from '../crypto/pgp.js'
import { mailboxHasRights } from '../util/acl.js'
import DownloadIcon from 'vue-material-design-icons/DownloadOutline.vue'
import CalendarClock from 'vue-material-design-icons/CalendarClockOutline.vue'
import AlarmIcon from 'vue-material-design-icons/Alarm.vue'
import moment from '@nextcloud/moment'
import { mapState, mapStores } from 'pinia'
import useMainStore from '../store/mainStore.js'
import { FOLLOW_UP_TAG_LABEL } from '../store/constants.js'
import { translateTagDisplayName } from '../util/tag.js'
export default {
name: 'Envelope',
components: {
AlertOctagonIcon,
Avatar,
IconCreateEvent,
CheckIcon,
ChevronLeft,
DeleteIcon,
ArchiveIcon,
TaskIcon,
DotsHorizontalIcon,
EnvelopePrimaryActions,
EventModal,
ImportantIcon,
ImportantOutlineIcon,
TaskModal,
EnvelopeSkeleton,
JunkIcon,
ActionButton,
MoveModal,
OpenInNewIcon,
PlusIcon,
TagIcon,
TagModal,
SparkleIcon,
Star,
StarOutline,
EmailRead,
EmailUnread,
IconAttachment,
IconBullet,
Reply,
ActionLink,
ActionText,
DownloadIcon,
ClockOutlineIcon,
NcActionSeparator,
NcActionInput,
CalendarClock,
AlarmIcon,
},
directives: {
draggableEnvelope: DraggableEnvelopeDirective,
},
props: {
withReply: {
// "Reply" action should only appear in envelopes from the envelope list
// (Because in thread envelopes, this action is already set as primary button of this menu)
type: Boolean,
default: true,
},
data: {
type: Object,
required: true,
},
mailbox: {
type: Object,
required: true,
},
selectMode: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
selectedEnvelopes: {
type: Array,
required: false,
default: () => [],
},
hasMultipleAccounts: {
type: Boolean,
default: false,
},
},
data() {
return {
showMoveModal: false,
showEventModal: false,
showTaskModal: false,
showTagModal: false,
moreActionsOpen: false,
snoozeOptions: false,
customSnoozeDateTime: new Date(moment().add(2, 'hours').minute(0).second(0).valueOf()),
overwriteOneLineMobile: false,
hoveringAvatar: false,
}
},
mounted() {
this.onWindowResize()
window.addEventListener('resize', this.onWindowResize)
},
computed: {
...mapStores(useMainStore),
...mapState(useMainStore, [
'isSnoozeDisabled',
]),
messageLongDate() {
return messageDateTime(new Date(this.data.dateInt))
},
oneLineLayout() {
return this.overwriteOneLineMobile ? false : this.mainStore.getPreference('layout-mode', 'vertical-split') === 'no-split'
},
layoutMessageViewThreaded() {
return this.mainStore.getPreference('layout-message-view', 'threaded') === 'threaded'
},
hasMultipleRecipients() {
if (!this.account) {
console.error('account is undefined', {
accountId: this.data.accountId,
})
}
const recipients = buildReplyRecipients(this.envelope, {
label: this.account.name,
email: this.account.emailAddress,
})
return recipients.to.concat(recipients.cc).length > 1
},
draft() {
return this.data.flags.draft
},
account() {
const accountId = this.data.accountId
return this.mainStore.getAccount(accountId)
},
link() {
if (this.draft) {
return undefined
} else {
return {
name: 'message',
params: {
mailboxId: this.$route.params.mailboxId,
filter: this.$route.params.filter ? this.$route.params.filter : undefined,
threadId: this.data.databaseId,
},
}
}
},
addresses() {
// Show recipients' label/address in a sent mailbox
if (this.mailbox.specialRole === 'sent' || this.account.sentMailboxId === this.mailbox.databaseId) {
const recipients = [this.data.to, this.data.cc].flat().map(function(recipient) {
return recipient.label ? recipient.label : recipient.email
})
return recipients.length > 0 ? recipients.join(', ') : t('mail', 'Blind copy recipients only')
}
// Show sender label/address in other mailbox types
return this.data.from[0]?.label ?? this.data.from[0]?.email ?? '?'
},
avatarEmail() {
// Show first recipients' avatar in a sent mailbox (or undefined when sent to Bcc only)
if (this.mailbox.specialRole === 'sent') {
const recipients = [this.data.to, this.data.cc].flat().map(function(recipient) {
return recipient.email
})
return recipients.length > 0 ? recipients[0] : ''
}
// Show sender avatar in other mailbox types
if (this.data.from.length > 0) {
return this.data.from[0].email
} else {
return ''
}
},
showArchiveButton() {
return this.account.archiveMailboxId !== null
},
disableArchiveButton() {
return this.account.archiveMailboxId !== null
&& this.account.archiveMailboxId === this.mailbox.databaseId
},
showFavoriteIconVariant() {
return !this.data.flags.flagged
},
showImportantIconVariant() {
return this.data.flags.seen
},
isEncrypted() {
return this.data.encrypted // S/MIME
|| (this.data.previewText && isPgpText(this.data.previewText)) // PGP/Mailvelope
},
isImportant() {
return this.mainStore
.getEnvelopeTags(this.data.databaseId)
.some((tag) => tag.imapLabel === '$label1')
},
tags() {
let tags = this.mainStore.getEnvelopeTags(this.data.databaseId).filter(
(tag) => tag.imapLabel && tag.imapLabel !== '$label1' && !(tag.displayName.toLowerCase() in hiddenTags),
)
// Don't show follow-up tag in unified mailbox as it has its own section at the top
if (this.mailbox.isUnified) {
tags = tags.filter((tag) => tag.imapLabel !== FOLLOW_UP_TAG_LABEL)
}
return tags
},
draggableLabel() {
let label = this.data.subject
const sender = this.data.from[0]?.label ?? this.data.from[0]?.email
if (sender) {
label += ` (${sender})`
}
return label
},
isDraggable() {
return mailboxHasRights(this.mailbox, 'te')
},
/**
* Subject of envelope or "No Subject".
*
* @return {string}
*/
subjectForSubtitle() {
const subject = this.data.subject || this.t('mail', 'No subject')
if (this.draft) {
return this.t('mail', '{markup-start}Draft:{markup-end} {subject}', {
'markup-start': '<em>',
'markup-end': '</em>',
subject: escapeHtml(subject),
}, {
escape: false,
})
}
return subject
},
/**
* Link to download the whole message (.eml).
*
* @return {string}
*/
exportMessageLink() {
return generateUrl('/apps/mail/api/messages/{id}/export', {
id: this.data.databaseId,
})
},
hasSeenAcl() {
return mailboxHasRights(this.mailbox, 's')
},
hasArchiveAcl() {
const hasDeleteSourceAcl = () => {
return mailboxHasRights(this.mailbox, 'te')
}
const hasCreateDestinationAcl = () => {
return mailboxHasRights(this.archiveMailbox, 'i')
}
return hasDeleteSourceAcl() && hasCreateDestinationAcl()
},
hasDeleteAcl() {
return mailboxHasRights(this.mailbox, 'te')
},
hasWriteAcl() {
return mailboxHasRights(this.mailbox, 'w')
},
archiveMailbox() {
return this.mainStore.getMailbox(this.account.archiveMailboxId)
},
isSnoozedMailbox() {
return this.mailbox.databaseId === this.account.snoozeMailboxId
},
reminderOptions() {
const currentDateTime = moment()
// Same day 18:00 PM (hidden if after 17:00 PM now)
const laterTodayTime = (currentDateTime.hour() < 17)
? moment().hour(18)
: null
// Tomorrow 08:00 AM
const tomorrowTime = moment().add(1, 'days').hour(8)
// Saturday 08:00 AM (hidden if Friday, Saturday or Sunday now)
const thisWeekendTime = (currentDateTime.day() > 0 && currentDateTime.day() < 5)
? moment().day(6).hour(8)
: null
// Next Monday 08:00 AM (hidden if Sunday now)
const nextWeekTime = (currentDateTime.day() !== 0)
? moment().add(1, 'weeks').day(1).hour(8)
: null
return [
{
key: 'laterToday',
timestamp: this.getTimestamp(laterTodayTime),
label: t('spreed', 'Later today {timeLocale}', { timeLocale: laterTodayTime?.format('LT') }),
ariaLabel: t('spreed', 'Set reminder for later today'),
},
{
key: 'tomorrow',
timestamp: this.getTimestamp(tomorrowTime),
label: t('spreed', 'Tomorrow {timeLocale}', { timeLocale: tomorrowTime?.format('ddd LT') }),
ariaLabel: t('spreed', 'Set reminder for tomorrow'),
},
{
key: 'thisWeekend',
timestamp: this.getTimestamp(thisWeekendTime),
label: t('spreed', 'This weekend {timeLocale}', { timeLocale: thisWeekendTime?.format('ddd LT') }),
ariaLabel: t('spreed', 'Set reminder for this weekend'),
},
{
key: 'nextWeek',
timestamp: this.getTimestamp(nextWeekTime),
label: t('spreed', 'Next week {timeLocale}', { timeLocale: nextWeekTime?.format('ddd LT') }),
ariaLabel: t('spreed', 'Set reminder for next week'),
},
].filter(option => option.timestamp !== null)
},
},
methods: {
translateTagDisplayName,
setSelected(value) {
if (this.selected !== value) {
this.$emit('update:selected', value)
}
},
formatted() {
return shortRelativeDatetime(new Date(this.data.dateInt * 1000))
},
unselect() {
if (this.selected) {
this.$emit('update:selected', false)
}
},
toggleSelected() {
this.$emit('update:selected', !this.selected)
},
async onClick(event) {
if (!event.ctrlKey && this.draft && !event.defaultPrevented) {
await this.mainStore.startComposerSession({
data: {
...this.data,
draftId: this.data.databaseId,
},
templateMessageId: this.data.databaseId,
})
}
},
onSelectMultiple() {
this.$emit('select-multiple')
},
onToggleImportant() {
this.mainStore.toggleEnvelopeImportant(this.data)
},
onToggleFlagged() {
this.mainStore.toggleEnvelopeFlagged(this.data)
},
onToggleSeen() {
this.mainStore.toggleEnvelopeSeen({ envelope: this.data })
},
async onToggleJunk() {
const removeEnvelope = await this.mainStore.moveEnvelopeToJunk(this.data)
if (this.isImportant) {
await this.mainStore.toggleEnvelopeImportant(this.data)
}
if (!this.data.flags.seen) {
await this.mainStore.toggleEnvelopeSeen({ envelope: this.data })
}
/**
* moveEnvelopeToJunk returns true if the envelope should be moved to a different mailbox.
*
* Our backend (MessageMapper.move) implemented move as copy and delete.
* The message is copied to another mailbox and gets a new UID; the message in the current folder is deleted.
*
* Trigger the delete event here to open the next envelope and remove the current envelope from the list.
* The delete event bubbles up to Mailbox.onDelete to the actual implementation.
*
* In Mailbox.onDelete, fetchNextEnvelopes requires the current envelope to find the next envelope.
* Therefore, it must run before removing the envelope.
*/
if (removeEnvelope) {
await this.$emit('delete', this.data.databaseId)
}
await this.mainStore.toggleEnvelopeJunk({
envelope: this.data,
removeEnvelope,
})
},
async onDelete() {
// Remove from selection first
this.setSelected(false)
// Delete
this.$emit('delete', this.data.databaseId)
try {
if (this.layoutMessageViewThreaded) {
await this.mainStore.deleteThread({
envelope: this.data,
})
} else {
await this.mainStore.deleteMessage({
id: this.data.databaseId,
})
}
} catch (error) {
showError(await matchError(error, {
[NoTrashMailboxConfiguredError.getName()]() {
return t('mail', 'No trash folder configured')
},
default(error) {
logger.error('could not delete message', error)
return t('mail', 'Could not delete message')
},
}))
}
},
showMoreActionOptions() {
this.snoozeOptions = false
this.moreActionsOpen = true
},
showSnoozeOptions() {
this.snoozeOptions = true
this.moreActionsOpen = false
},
closeMoreAndSnoozeOptions() {
this.snoozeOptions = false
this.moreActionsOpen = false
},
async onArchive() {
// Remove from selection first
this.setSelected(false)
// Archive
this.$emit('archive', this.data.databaseId)
try {
if (this.layoutMessageViewThreaded) {
await this.mainStore.moveThread({
envelope: this.data,
destMailboxId: this.account.archiveMailboxId,
})
} else {
await this.mainStore.moveMessage({
id: this.data.databaseId,
destMailboxId: this.account.archiveMailboxId,
})
}
} catch (error) {
logger.error('could not archive message', error)
showError(t('mail', 'Could not archive message'))
}
},
async onSnooze(timestamp) {
// Remove from selection first
this.setSelected(false)
if (!this.account.snoozeMailboxId) {
await this.mainStore.createAndSetSnoozeMailbox(this.account)
}
try {
if (this.layoutMessageViewThreaded) {
await this.mainStore.snoozeThread({
envelope: this.data,
unixTimestamp: timestamp / 1000,
destMailboxId: this.account.snoozeMailboxId,
})
} else {
await this.mainStore.snoozeMessage({
id: this.data.databaseId,
unixTimestamp: timestamp / 1000,
destMailboxId: this.account.snoozeMailboxId,
})
}
showSuccess(t('mail', 'Thread was snoozed'))
} catch (error) {
logger.error('could not snooze thread', error)
showError(t('mail', 'Could not snooze thread'))
}
},
async onUnSnooze() {
// Remove from selection first
this.setSelected(false)
try {
if (this.layoutMessageViewThreaded) {
await this.mainStore.unSnoozeThread({
envelope: this.data,
})
} else {
await this.mainStore.unSnoozeMessage({
id: this.data.databaseId,
})
}
showSuccess(t('mail', 'Thread was unsnoozed'))
} catch (error) {
logger.error('Could not unsnooze thread', error)
showError(t('mail', 'Could not unsnooze thread'))
}
},
async onOpenEditAsNew() {
await this.mainStore.startComposerSession({
templateMessageId: this.data.databaseId,
data: this.data,
})
},
onOpenMoveModal() {
this.showMoveModal = true
},
onOpenEventModal() {
this.showEventModal = true
},
onMove() {
this.$emit('move')
},
onCloseMoveModal() {
this.showMoveModal = false
},
onOpenTagModal() {
this.showTagModal = true
},
onCloseTagModal() {
this.showTagModal = false
},
getTimestamp(momentObject) {
return momentObject?.minute(0).second(0).millisecond(0).valueOf() || null
},
setCustomSnoozeDateTime(event) {
this.customSnoozeDateTime = new Date(event.target.value)
},
setCustomSnooze() {
this.onSnooze(this.customSnoozeDateTime.valueOf())
},
onWindowResize() {
const widthOutput = window.innerWidth
if (widthOutput <= 700) {
this.overwriteOneLineMobile = true
} else {
this.overwriteOneLineMobile = false
}
},
},
}
</script>
<style lang="scss" scoped>
.mail-message-account-color {
position: absolute;
inset-inline-start: 0px;
width: 2px;
height: 69px;
z-index: 1;
}
.envelope {
.app-content-list-item-icon {
height: 40px; // To prevent some unexpected spacing below the avatar
}
&__subtitle {
display: flex;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
align-items: center;
&__subject {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--default-line-height);
&__text {
&.draft {
line-height: 130%;
/* deep because there is no data attribute for the em rendered from JS output */
:deep(em) {
font-style: italic;
}
}
}
}
}
&__preview-text {
color: var(--color-text-maxcontrast);
overflow: hidden;
font-weight: initial;
max-height: calc(var(--default-font-size) * var(--default-line-height) * 2);
/* Weird CSS hacks to make text ellipsize without white-space: nowrap */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
.material-design-icon {
display: inline;
position: relative;
top: 2px;
}
}
}
.list-item__wrapper--active {
div, :deep(.list-item-content__inner__details__details) {
color: var(--color-primary-element-text) !important;
}
}
.icon-important {
:deep(path) {
fill: #ffcc00;
stroke: var(--color-main-background);
stroke-width: 2;
}
.list-item:hover &,
.list-item:focus &,
.list-item.active & {
:deep(path) {
stroke: var(--color-background-dark);
}
}
// In message list, but not the one in the action menu
&.app-content-list-item-star {
background-image: none;
inset-inline-start: 1px;
top: 8px;
opacity: 1;
&:hover,
&:focus {
opacity: 0.5;
}
}
}
.important-one-line.app-content-list-item-star:deep() {
top: 4px !important;
inset-inline-start: 2px;
}
.app-content-list-item-select-checkbox {
display: inline-block;
vertical-align: middle;
position: absolute;
inset-inline-start: 33px;
top: 35px;
z-index: 50; // same as icon-starred
}
.list-item-style:not(.seen) {
font-weight: bold;
}
.junk-icon-style {
opacity: .2;
display: flex;
top: 32px;
inset-inline-start: 32px;
background-size: 16px;
height: 20px;
width: 20px;
margin: 0;
padding: 0;
position: absolute;
z-index: 2;
&:hover {
opacity: .1;
}
}
.one-line.junk-icon-style {
top: 36px;
}
.icon-attachment {
-ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=25)';
opacity: 0.25;
}
:deep(.action--primary) {
.material-design-icon {
margin-bottom: -14px;
}
}
.tag-group__label {
margin: 0 7px;
z-index: 2;
font-size: calc(var(--default-font-size) * 0.8);
font-weight: bold;
padding-inline: 2px;
white-space: nowrap;
}
.tag-group__bg {
position: absolute;
width: 100%;
height: 100%;
top: 0;
inset-inline-start: 0;
opacity: 15%;
}
.tag-group {
display: inline-block;
border-radius: var(--border-radius-pill);
position: relative;
margin-inline-end: 1px;
overflow: hidden;
text-overflow: ellipsis;
}
.list-item__wrapper:deep() {
list-style: none;
}
.icon-important.app-content-list-item-star:deep() {
position: absolute;
top: 3px;
z-index: 1;
stroke: var(--color-main-background);
stroke-width: 2;
}
.app-content-list-item-star.favorite-icon-style {
display: inline-block;
position: absolute;
top: 3px;
inset-inline-start: 30px;
cursor: pointer;
stroke: var(--color-main-background);
stroke-width: 2;
z-index: 1;
&:hover {
opacity: .4;
}
}
.one-line.favorite-icon-style {
top: 3px;
inset-inline-start: 31px;
}
.seen-icon-style,
.attachment-icon-style {
opacity: .6;
display: inline-flex;
align-items: center;
margin-inline-end: 5px;
}
:deep(.list-item__anchor) {
margin-top: 6px;
margin-bottom: 6px;
}
:deep(.line-two__subtitle) {
display: flex;
flex-basis: 100%;
padding-inline-start: 40px;
width: 450px;
}
:deep(.line-one__title) {
flex-direction: row;
display: flex;
width: 200px;
}
.line-two.one-line {
display: flex;
overflow: hidden;
align-items: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.envelope__subtitle__subject.one-line {
display: flex;
align-items: center;
height: calc(var(--default-font-size) * var(--default-line-height));
&::after {
content: '\00B7';
margin: 12px;
}
}
.envelope__subtitle__subject__text.one-line {
max-width: 300px;
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
}
.app-content-list-item-avatar-selected {
background-color: var(--color-primary-element);
color: var(--color-primary-light);
border-radius: 32px;
&:hover {
background-color: var(--color-primary-element);
color: var(--color-primary-light);
border-radius: 32px;
}
}
.hover-active {
&:hover {
color: var(--color-primary-hover);
background-color: var(--color-primary-light-hover);
border-radius: 32px;
}
}
.check-icon {
border-radius: 32px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
</style>