Files

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>