Files

413 lines
11 KiB
Vue

// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {t} from '../i18n.ts';
import {defineComponent, ref, computed, onMounted, onBeforeUnmount} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import type {Thread} from '../types.ts';
import AvatarCircle from './AvatarCircle.vue';
type Tab = 'direct' | 'group';
// Compose panel: toggled below the search bar. Closes on Escape or outside click.
// Emits "create" with a fully-formed Thread to be inserted into the list, and "close".
export default defineComponent({
name: 'ComposePanel',
components: {SvgIcon, AvatarCircle},
emits: ['create', 'close'],
setup(_props, {emit}) {
const tab = ref<Tab>('direct');
// Direct tab state
const directEmail = ref('');
const directBody = ref('');
const directSubject = ref('');
const editingSubject = ref(false);
const autoSubject = computed(() => {
const first = directBody.value.trim().split(/[.!?\n]/)[0] ?? '';
return first.slice(0, 60).trim();
});
const effectiveSubject = computed(() => directSubject.value || autoSubject.value);
// Group tab state
const groupName = ref('');
const groupEmailInput = ref('');
const groupChips = ref<string[]>([]);
const groupBody = ref('');
function setTab(t: Tab) {
tab.value = t;
}
function autoGrow(e: Event) {
const el = e.target as HTMLTextAreaElement;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
function commitSubjectIfEditing() {
editingSubject.value = false;
}
function addChip() {
// Trim and strip any trailing delimiter (comma, semicolon, space) the user may have typed
const v = groupEmailInput.value.trim().replace(/[,;\s]+$/, '');
if (v && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && !groupChips.value.includes(v)) {
groupChips.value.push(v);
}
groupEmailInput.value = '';
}
function onGroupInputKeydown(e: KeyboardEvent) {
// Delimiters that commit the current input as a chip
if (e.key === 'Enter' || e.key === ',' || e.key === ';' || e.key === ' ') {
e.preventDefault();
addChip();
} else if (e.key === 'Backspace' && groupEmailInput.value === '' && groupChips.value.length) {
groupChips.value.pop();
}
}
function removeChip(idx: number) {
groupChips.value.splice(idx, 1);
}
function createDirect() {
const email = directEmail.value.trim();
if (!email || !directBody.value.trim()) return;
const name = email.slice(0, email.indexOf('@'));
const thread: Thread = {
id: 'dm-' + email.toLowerCase() + '-' + Date.now(),
isGroup: false,
subject: effectiveSubject.value || t('mail.new_conversation'),
pinned: false,
unread: false,
participants: [
{email: 'd@m8sh.su', name: 'Danila', self: true},
{email, name},
],
messages: [{
id: 'c-' + Date.now(),
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: effectiveSubject.value,
body: directBody.value.trim(),
timestamp: Date.now(),
}],
};
emit('create', thread);
reset();
}
function createGroup() {
if (!groupName.value.trim() || groupChips.value.length === 0 || !groupBody.value.trim()) return;
const participants = [
{email: 'd@m8sh.su', name: 'Danila', self: true},
...groupChips.value.map((email) => ({email, name: email.slice(0, email.indexOf('@'))})),
];
const thread: Thread = {
id: 'group-' + Date.now(),
isGroup: true,
subject: groupName.value.trim(),
pinned: false,
unread: false,
participants,
messages: [{
id: 'c-' + Date.now(),
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: groupName.value.trim(),
body: groupBody.value.trim(),
timestamp: Date.now(),
}],
};
emit('create', thread);
reset();
}
function reset() {
directEmail.value = '';
directBody.value = '';
directSubject.value = '';
editingSubject.value = false;
groupName.value = '';
groupEmailInput.value = '';
groupChips.value = [];
groupBody.value = '';
emit('close');
}
// Close on Escape; outside click handled by parent via backdrop.
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
return {
t,
tab, setTab,
directEmail, directBody, directSubject, editingSubject,
autoSubject, effectiveSubject, commitSubjectIfEditing,
groupName, groupEmailInput, groupChips, groupBody,
autoGrow, addChip, onGroupInputKeydown, removeChip,
createDirect, createGroup, reset,
};
},
});
</script>
<template>
<div class="compose-panel" @click.stop>
<div class="compose-tabs">
<button type="button" class="compose-tab" :class="{active: tab === 'direct'}" @click="setTab('direct')">
{{ t('mail.direct') }}
</button>
<button type="button" class="compose-tab" :class="{active: tab === 'group'}" @click="setTab('group')">
{{ t('mail.group') }}
</button>
</div>
<div v-if="tab === 'direct'" class="compose-body">
<input v-model="directEmail" class="compose-input" type="email" :placeholder="t('mail.to_email')">
<div class="compose-subject">
<template v-if="!editingSubject">
<span
v-if="effectiveSubject"
class="subject-preview"
:title="t('mail.click_to_edit')"
@click="editingSubject = true; directSubject = effectiveSubject"
>{{ t('mail.subject_label').replace('%s', effectiveSubject) }}</span>
</template>
<input
v-else
v-model="directSubject"
class="compose-input subject-input"
:placeholder="t('mail.subject')"
@blur="commitSubjectIfEditing"
@keydown.enter="commitSubjectIfEditing"
>
</div>
<textarea
v-model="directBody"
class="compose-textarea"
:placeholder="t('mail.write_message')"
rows="2"
@input="autoGrow"
/>
<div class="compose-actions">
<button type="button" class="compose-send" :disabled="!directEmail || !directBody" @click="createDirect">{{ t('mail.send') }}</button>
</div>
</div>
<div v-else class="compose-body">
<input v-model="groupName" class="compose-input" :placeholder="t('mail.group_name_plain')">
<div class="chip-field">
<span v-for="(chip, idx) in groupChips" :key="chip" class="chip">
<AvatarCircle :email="chip" :size="16"/>
<span class="chip-email">{{ chip }}</span>
<button type="button" class="chip-remove" :title="'Remove ' + chip" @click="removeChip(idx)">
<SvgIcon name="octicon-x" :size="12"/>
</button>
</span>
<input
v-model="groupEmailInput"
class="chip-input"
type="email"
:placeholder="t('mail.add_member_email')"
@keydown="onGroupInputKeydown"
@blur="addChip"
>
</div>
<textarea
v-model="groupBody"
class="compose-textarea"
:placeholder="t('mail.write_message')"
rows="2"
@input="autoGrow"
/>
<div class="compose-actions">
<button
type="button"
class="compose-send"
:disabled="!groupName || !groupChips.length || !groupBody"
@click="createGroup"
>
{{ t('mail.create_group') }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.compose-panel {
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
background: var(--color-body);
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.compose-tabs {
display: flex;
gap: 4px;
}
.compose-tab {
flex: 1;
border: none;
background: transparent;
color: var(--color-text-light-2);
font-size: 13px;
padding: 6px;
border-radius: var(--border-radius);
cursor: pointer;
}
.compose-tab.active {
background: var(--color-secondary-bg);
color: var(--color-text);
font-weight: var(--font-weight-medium);
}
.compose-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.compose-input,
.compose-textarea {
width: 100%;
font-size: 13px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 8px 10px;
outline: none;
}
.compose-input:focus,
.compose-textarea:focus {
border-color: var(--color-primary);
}
.chip-input {
flex: 1;
min-width: 140px;
font-size: 13px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: transparent;
border: none;
outline: none;
padding: 4px;
}
.compose-textarea {
resize: none;
overflow-y: auto;
min-height: 36px;
max-height: 140px;
line-height: 1.4;
}
.compose-subject {
min-height: 0;
}
.subject-preview {
font-size: 11px;
font-style: italic;
color: var(--color-text-light-2);
cursor: text;
display: inline-block;
max-width: 100%;
}
.subject-input {
font-style: italic;
}
.chip-field {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 4px 6px;
}
.chip-field:focus-within {
border-color: var(--color-primary);
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
border-radius: var(--border-radius-full);
padding: 1px 4px 1px 2px;
font-size: 12px;
}
.chip-email {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-remove {
border: none;
background: transparent;
color: var(--color-text-light-2);
cursor: pointer;
padding: 2px;
display: inline-flex;
border-radius: var(--border-radius-full);
}
.chip-remove:hover {
color: var(--color-danger);
}
.compose-actions {
display: flex;
justify-content: flex-end;
}
.compose-send {
border: none;
background: hsl(214, 70%, 93%);
color: hsl(214, 70%, 30%);
font-size: 13px;
font-weight: var(--font-weight-medium);
padding: 6px 14px;
border-radius: var(--border-radius-medium);
cursor: pointer;
}
.compose-send:disabled {
opacity: var(--opacity-disabled);
cursor: not-allowed;
}
</style>