510 lines
16 KiB
Vue
510 lines
16 KiB
Vue
// Copyright 2026 The M8SH Authors.
|
|
// SPDX-License-Identifier: GPL-3.0
|
|
|
|
<script lang="ts">
|
|
import {t} from '../i18n.ts';
|
|
import {defineComponent, type PropType, computed, ref, onMounted, onBeforeUnmount} from 'vue';
|
|
import {SvgIcon} from '../../../svg.ts';
|
|
import type {BlockEntry, BlockScope, Thread} from '../types.ts';
|
|
import {threadDisplayName, threadAvatarEmail} from '../threadTitle.ts';
|
|
import {htmlTextContent} from '../highlight.ts';
|
|
import AvatarCircle from './AvatarCircle.vue';
|
|
|
|
// Left panel thread list with pinned + messages sections, search filtering,
|
|
// and a right-click / long-press context menu with an expandable Block group.
|
|
export default defineComponent({
|
|
name: 'ThreadList',
|
|
components: {SvgIcon, AvatarCircle},
|
|
props: {
|
|
threads: {type: Array as PropType<Thread[]>, required: true},
|
|
activeId: {type: String, default: ''},
|
|
query: {type: String, default: ''},
|
|
blocked: {type: Array as PropType<BlockEntry[]>, default: () => []},
|
|
isThreadBlocked: {type: Function as PropType<(t: Thread) => boolean>, required: true},
|
|
blockScopeOf: {type: Function as PropType<(t: Thread) => BlockScope | null>, required: true},
|
|
hasMore: {type: Boolean, default: false},
|
|
},
|
|
emits: ['select', 'toggle-pin', 'mark-unread', 'delete', 'block', 'unblock', 'load-more'],
|
|
setup(props, {emit}) {
|
|
// Name/email resolution is centralised in threadTitle.ts for the backend merge.
|
|
const threadName = threadDisplayName;
|
|
const threadEmail = threadAvatarEmail;
|
|
|
|
function lastMessage(thread: Thread) {
|
|
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
const m = thread.messages[i];
|
|
if (m.kind === 'text' || m.kind === 'html') return m;
|
|
}
|
|
return thread.messages[thread.messages.length - 1];
|
|
}
|
|
|
|
function lastPreview(thread: Thread): string {
|
|
const m = lastMessage(thread);
|
|
if (!m) return '';
|
|
// Use server-extracted preview when available (robust: parsed on backend)
|
|
if (m.preview) return m.preview;
|
|
// Fallback for messages without a preview (e.g. sent from M8SH UI)
|
|
if (m.kind === 'html') {
|
|
const text = htmlTextContent(m.body).trim();
|
|
return text.length > 80 ? text.slice(0, 80) + '…' : text || 'HTML message';
|
|
}
|
|
return m.body;
|
|
}
|
|
|
|
function lastTimestamp(thread: Thread): number {
|
|
const m = lastMessage(thread);
|
|
return m ? m.timestamp : 0;
|
|
}
|
|
|
|
function formatRelative(ts: number): string {
|
|
const diff = Date.now() - ts;
|
|
const min = 60_000, hour = 60 * min, day = 24 * hour;
|
|
if (diff < min) return 'now';
|
|
if (diff < hour) return Math.floor(diff / min) + 'm';
|
|
if (diff < day) return Math.floor(diff / hour) + 'h';
|
|
if (diff < 7 * day) return Math.floor(diff / day) + 'd';
|
|
return new Date(ts).toLocaleDateString([], {month: 'short', day: 'numeric'});
|
|
}
|
|
|
|
const filtered = computed<Thread[]>(() => {
|
|
const q = props.query.trim().toLowerCase();
|
|
if (!q) return props.threads;
|
|
return props.threads.filter((thread) => {
|
|
return threadName(thread).toLowerCase().includes(q) || lastPreview(thread).toLowerCase().includes(q);
|
|
});
|
|
});
|
|
|
|
// Sort by last message timestamp (most recent first), then by name for stability.
|
|
// Pinned threads always appear first. This is client-side so the list stays
|
|
// correct even when the backend's updated_at lags (e.g. after deletions).
|
|
const pinned = computed(() => {
|
|
return filtered.value
|
|
.filter((thread) => thread.pinned)
|
|
.sort((a, b) => lastTimestamp(b) - lastTimestamp(a) || a.id.localeCompare(b.id));
|
|
});
|
|
const others = computed(() => {
|
|
return filtered.value
|
|
.filter((thread) => !thread.pinned)
|
|
.sort((a, b) => lastTimestamp(b) - lastTimestamp(a) || a.id.localeCompare(b.id));
|
|
});
|
|
|
|
// Context menu. Delete sits at the top; Block is an expandable toggle whose
|
|
// options render below it when open.
|
|
const menu = ref<{threadId: string, x: number, y: number} | null>(null);
|
|
const submenuOpen = ref(false);
|
|
const deleteConfirmOpen = ref(false);
|
|
let longPressTimer: number | undefined;
|
|
// Guard timestamp: some browsers (e.g. Firefox) fire a `click` on right-click;
|
|
// ignore clicks within this window so the menu doesn't close instantly.
|
|
let menuOpenedAt = 0;
|
|
|
|
function openMenu(t: Thread, x: number, y: number) {
|
|
// clamp so the menu never renders off the right/bottom edge, where it
|
|
// would appear "missing" to the user.
|
|
const w = window.innerWidth;
|
|
const h = window.innerHeight;
|
|
const maxX = Math.max(0, w - 180);
|
|
const maxY = Math.max(0, h - 320);
|
|
menu.value = {threadId: t.id, x: Math.min(x, maxX), y: Math.min(y, maxY)};
|
|
submenuOpen.value = false;
|
|
menuOpenedAt = Date.now();
|
|
}
|
|
|
|
function onContextMenu(e: MouseEvent, t: Thread) {
|
|
e.preventDefault();
|
|
openMenu(t, e.clientX, e.clientY);
|
|
}
|
|
|
|
function onTouchStart(e: TouchEvent, t: Thread) {
|
|
const touch = e.touches[0];
|
|
longPressTimer = window.setTimeout(() => {
|
|
openMenu(t, touch.clientX, touch.clientY);
|
|
}, 500);
|
|
}
|
|
|
|
function clearLongPress() {
|
|
if (longPressTimer) {
|
|
clearTimeout(longPressTimer);
|
|
longPressTimer = undefined;
|
|
}
|
|
}
|
|
|
|
function closeMenu() {
|
|
menu.value = null;
|
|
submenuOpen.value = false;
|
|
deleteConfirmOpen.value = false;
|
|
}
|
|
|
|
function menuThread(): Thread | undefined {
|
|
return props.threads.find((t) => t.id === menu.value?.threadId);
|
|
}
|
|
|
|
function pinFromMenu() {
|
|
if (menu.value) emit('toggle-pin', menu.value.threadId);
|
|
closeMenu();
|
|
}
|
|
|
|
function unreadFromMenu() {
|
|
if (menu.value) emit('mark-unread', menu.value.threadId);
|
|
closeMenu();
|
|
}
|
|
|
|
function deleteFromMenu() {
|
|
deleteConfirmOpen.value = true;
|
|
}
|
|
|
|
function confirmDeleteFromMenu() {
|
|
if (menu.value) emit('delete', menu.value.threadId);
|
|
closeMenu();
|
|
}
|
|
|
|
function cancelDeleteFromMenu() {
|
|
deleteConfirmOpen.value = false;
|
|
}
|
|
|
|
function toggleSubmenu() {
|
|
submenuOpen.value = !submenuOpen.value;
|
|
}
|
|
|
|
function blockFromMenu(scope: BlockScope, alsoDelete: boolean) {
|
|
if (menu.value) emit('block', menu.value.threadId, scope, alsoDelete);
|
|
closeMenu();
|
|
}
|
|
|
|
function unblockFromMenu() {
|
|
if (menu.value) emit('unblock', menu.value.threadId);
|
|
closeMenu();
|
|
}
|
|
|
|
function onWindowClickClose(e: MouseEvent) {
|
|
// ignore the synthesized click that some browsers fire right after contextmenu
|
|
if (Date.now() - menuOpenedAt < 350) return;
|
|
// ignore clicks inside the menu itself
|
|
if ((e.target as HTMLElement)?.closest('.ctx-menu')) return;
|
|
closeMenu();
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
if (submenuOpen.value) submenuOpen.value = false;
|
|
else closeMenu();
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('click', onWindowClickClose);
|
|
window.addEventListener('keydown', onKeydown);
|
|
});
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('click', onWindowClickClose);
|
|
window.removeEventListener('keydown', onKeydown);
|
|
clearLongPress();
|
|
});
|
|
|
|
// Infinite scroll: observe a sentinel at the bottom of the list.
|
|
const sentinel = ref<HTMLElement | null>(null);
|
|
let observer: IntersectionObserver | null = null;
|
|
onMounted(() => {
|
|
if (sentinel.value) {
|
|
observer = new IntersectionObserver((entries) => {
|
|
if (entries[0]?.isIntersecting && props.hasMore) {
|
|
emit('load-more');
|
|
}
|
|
}, {root: null, rootMargin: '100px', threshold: 0});
|
|
observer.observe(sentinel.value);
|
|
}
|
|
});
|
|
onBeforeUnmount(() => {
|
|
observer?.disconnect();
|
|
observer = null;
|
|
});
|
|
|
|
return {
|
|
t,
|
|
filtered, pinned, others,
|
|
threadName, threadEmail, lastPreview, lastTimestamp, formatRelative,
|
|
menu, submenuOpen, deleteConfirmOpen, onContextMenu, onTouchStart, clearLongPress, closeMenu, menuThread,
|
|
pinFromMenu, unreadFromMenu, deleteFromMenu, confirmDeleteFromMenu, cancelDeleteFromMenu,
|
|
toggleSubmenu, blockFromMenu, unblockFromMenu,
|
|
sentinel,
|
|
};
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="thread-list">
|
|
<!-- pinned section -->
|
|
<template v-if="pinned.length">
|
|
<div class="list-section-label">
|
|
<SvgIcon name="octicon-pin" :size="12"/>
|
|
{{ t('mail.pinned') }}
|
|
</div>
|
|
<div
|
|
v-for="thread in pinned" :key="thread.id"
|
|
class="thread-item"
|
|
:class="{active: thread.id === activeId, unread: thread.unread, blocked: isThreadBlocked(thread)}"
|
|
@click="$emit('select', thread.id)"
|
|
@contextmenu="onContextMenu($event, thread)"
|
|
@touchstart.passive="onTouchStart($event, thread)"
|
|
@touchend="clearLongPress"
|
|
@touchmove="clearLongPress"
|
|
>
|
|
<AvatarCircle :email="threadEmail(thread)" :name="threadName(thread)" :is-group="thread.isGroup" :size="36"/>
|
|
<div class="ti-center">
|
|
<div class="ti-name">
|
|
{{ threadName(thread) }}
|
|
<span v-if="isThreadBlocked(thread)" class="ti-blocked-tag">
|
|
{{ blockScopeOf(thread) === 'domain' ? t('mail.blocked_domain') : t('mail.blocked') }}
|
|
</span>
|
|
</div>
|
|
<div class="ti-preview">{{ lastPreview(thread) }}</div>
|
|
</div>
|
|
<div class="ti-right">
|
|
<div class="ti-time">{{ formatRelative(lastTimestamp(thread)) }}</div>
|
|
<SvgIcon v-if="thread.pinned" name="octicon-pin" :size="14" class="ti-pin"/>
|
|
<span v-else-if="thread.unread" class="ti-dot"/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="list-section-label">{{ t('mail.messages') }}</div>
|
|
<div
|
|
v-for="thread in others" :key="thread.id"
|
|
class="thread-item"
|
|
:class="{active: thread.id === activeId, unread: thread.unread, blocked: isThreadBlocked(thread)}"
|
|
@click="$emit('select', thread.id)"
|
|
@contextmenu="onContextMenu($event, thread)"
|
|
@touchstart.passive="onTouchStart($event, thread)"
|
|
@touchend="clearLongPress"
|
|
@touchmove="clearLongPress"
|
|
>
|
|
<AvatarCircle :email="threadEmail(thread)" :name="threadName(thread)" :is-group="thread.isGroup" :size="36"/>
|
|
<div class="ti-center">
|
|
<div class="ti-name">
|
|
{{ threadName(thread) }}
|
|
<span v-if="isThreadBlocked(thread)" class="ti-blocked-tag">
|
|
{{ blockScopeOf(thread) === 'domain' ? t('mail.blocked_domain') : t('mail.blocked') }}
|
|
</span>
|
|
</div>
|
|
<div class="ti-preview">{{ lastPreview(thread) }}</div>
|
|
</div>
|
|
<div class="ti-right">
|
|
<div class="ti-time">{{ formatRelative(lastTimestamp(thread)) }}</div>
|
|
<SvgIcon v-if="thread.pinned" name="octicon-pin" :size="14" class="ti-pin"/>
|
|
<span v-else-if="thread.unread" class="ti-dot"/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!filtered.length" class="empty">{{ t('mail.no_conversations') }}</div>
|
|
|
|
<!-- Infinite scroll sentinel: when this enters the viewport, load more threads -->
|
|
<div v-if="hasMore" ref="sentinel" class="load-sentinel"/>
|
|
|
|
<!-- context menu (teleported; styles live unscoped in mail.css).
|
|
Order: Pin, Mark unread, Delete, then an expandable Block toggle. -->
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="menu"
|
|
class="ctx-menu"
|
|
:style="{left: menu.x + 'px', top: menu.y + 'px'}"
|
|
@click.stop
|
|
>
|
|
<button type="button" class="ctx-item" @click="pinFromMenu">
|
|
<SvgIcon name="octicon-pin" :size="14"/>
|
|
{{ menuThread()?.pinned ? t('mail.unpin') : t('mail.pin') }}
|
|
</button>
|
|
<button type="button" class="ctx-item" @click="unreadFromMenu">
|
|
<SvgIcon name="octicon-dot-fill" :size="14"/>
|
|
{{ t('mail.mark_unread') }}
|
|
</button>
|
|
<button type="button" class="ctx-item danger" @click="deleteFromMenu">
|
|
<SvgIcon name="octicon-trash" :size="14"/>
|
|
{{ t('mail.delete') }}
|
|
</button>
|
|
|
|
<!-- Block: expandable toggle (DMs only). Renders its options below. -->
|
|
<button
|
|
v-if="menuThread() && !menuThread()!.isGroup && !isThreadBlocked(menuThread()!)"
|
|
type="button"
|
|
class="ctx-item danger ctx-toggle"
|
|
:class="{expanded: submenuOpen}"
|
|
@click="toggleSubmenu"
|
|
>
|
|
<SvgIcon name="octicon-blocked" :size="14"/>
|
|
{{ t('mail.block_sender') }}
|
|
<SvgIcon
|
|
name="octicon-chevron-down"
|
|
:size="12"
|
|
class="ctx-chev"
|
|
:class="{up: submenuOpen}"
|
|
/>
|
|
</button>
|
|
<button
|
|
v-else-if="menuThread() && !menuThread()!.isGroup && isThreadBlocked(menuThread()!)"
|
|
type="button" class="ctx-item danger" @click="unblockFromMenu"
|
|
>
|
|
<SvgIcon name="octicon-blocked" :size="14"/>
|
|
{{ t('mail.unblock_sender') }}
|
|
</button>
|
|
|
|
<!-- Expandable Block options (no icons; text flush left) -->
|
|
<div v-if="submenuOpen && menuThread() && !menuThread()!.isGroup" class="ctx-expand">
|
|
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('user', false)">
|
|
{{ t('mail.block_sender') }}
|
|
</button>
|
|
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('user', true)">
|
|
{{ t('mail.block_delete') }}
|
|
</button>
|
|
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('domain', true)">
|
|
{{ t('mail.block_domain') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- Delete conversation confirmation dialog (same overlay as ChatPane) -->
|
|
<Teleport to="body">
|
|
<div v-if="deleteConfirmOpen" class="confirm-overlay" @click.self="cancelDeleteFromMenu">
|
|
<div class="confirm-modal">
|
|
<div class="confirm-icon">
|
|
<SvgIcon name="octicon-trash" :size="24"/>
|
|
</div>
|
|
<div class="confirm-title">{{ t('mail.delete_conversation') }}</div>
|
|
<div class="confirm-desc">{{ t('mail.delete_conversation_desc_generic') }}</div>
|
|
<div class="confirm-actions">
|
|
<button type="button" class="confirm-cancel" @click="cancelDeleteFromMenu">{{ t('mail.cancel') }}</button>
|
|
<button type="button" class="confirm-delete" @click="confirmDeleteFromMenu">
|
|
<SvgIcon name="octicon-trash" :size="14"/>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.thread-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.list-section-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 10px;
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-text-light-2);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
padding: 12px 14px 4px;
|
|
}
|
|
|
|
.thread-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 14px;
|
|
cursor: pointer;
|
|
min-height: 56px; /* >= 44px touch target */
|
|
}
|
|
|
|
.thread-item:hover {
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
.thread-item.active {
|
|
background: var(--color-secondary-bg);
|
|
}
|
|
|
|
.thread-item.blocked {
|
|
opacity: var(--opacity-disabled);
|
|
}
|
|
|
|
.ti-center {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.ti-name {
|
|
font-size: 13px;
|
|
font-weight: var(--font-weight-normal);
|
|
color: var(--color-text);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.thread-item.unread .ti-name {
|
|
font-weight: var(--font-weight-medium);
|
|
}
|
|
|
|
.ti-blocked-tag {
|
|
font-size: 10px;
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-danger);
|
|
background: var(--color-red-badge-bg);
|
|
border: 0.5px solid var(--color-red-badge);
|
|
border-radius: var(--border-radius);
|
|
padding: 0 4px;
|
|
line-height: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.ti-preview {
|
|
font-size: 11px;
|
|
color: var(--color-text-light-2);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.ti-right {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.ti-time {
|
|
font-size: 10px;
|
|
color: var(--color-text-light-2);
|
|
}
|
|
|
|
.ti-pin {
|
|
color: var(--color-text-light-2);
|
|
}
|
|
|
|
.ti-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: var(--border-radius-full);
|
|
background: var(--color-primary);
|
|
}
|
|
|
|
.empty {
|
|
padding: 24px 14px;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
color: var(--color-text-light-2);
|
|
}
|
|
|
|
/* Sentinel for IntersectionObserver-based infinite scroll */
|
|
.load-sentinel {
|
|
height: 1px;
|
|
width: 100%;
|
|
}
|
|
</style>
|