Files
reactive-mail-server/frontend/src/app/pages/folder/folder.page.ts
T
2026-11-07 03:21:15 +01:00

293 lines
14 KiB
TypeScript

import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
JtGridBulkEvent, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction
} from '@jambotron/aria-grid';
import { Observable, forkJoin, of } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { Message, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** One folder: the message list (grid), search, bulk actions, empty Trash/Spam. */
@Component({
selector: 'app-folder',
imports: [DatePipe, RouterLink, MatIconModule, MatTooltipModule, JtGridComponent, JtGridCellDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="glass-panel static p-4 md:p-6" [attr.aria-labelledby]="'folder-title'">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div class="flex items-center gap-3 min-w-0">
<mat-icon class="text-pink-400">{{ icon() }}</mat-icon>
<div class="min-w-0">
<h1 id="folder-title" class="text-xl font-orbitron font-bold neon-text-secondary truncate">{{ title() }}</h1>
<p class="text-xs text-slate-400 font-mono">
@if (q()) { Search “{{ q() }}”: {{ messages().length }} found }
@else { {{ messages().length }} messages · {{ unread() }} unread }
</p>
</div>
</div>
<div class="flex items-center gap-2">
@if (q()) {
<a [routerLink]="['/folder', folderId()]" class="ghost-btn"><mat-icon>close</mat-icon> Clear search</a>
}
<button type="button" class="ghost-btn" (click)="load()" [disabled]="loading()" aria-label="Refresh" matTooltip="Refresh">
<mat-icon [class.animate-spin]="loading()">refresh</mat-icon>
</button>
@if (canEmpty()) {
<button type="button" class="ghost-btn danger" (click)="empty()" [disabled]="!messages().length">
<mat-icon>delete_forever</mat-icon> Empty {{ title() }}
</button>
}
</div>
</div>
@if (error()) {
<p class="text-pink-400 font-rajdhani mb-3" role="alert">{{ error() }}</p>
}
<jt-grid [rows]="messages()" [columns]="columns()" [trackBy]="byId" [label]="title() + ' messages'"
[emptyMessage]="loading() ? 'Loading…' : q() ? 'Nothing found' : 'No messages here'"
[bulkActions]="bulkActions()" (bulkAction)="onBulk($event)"
[rowActions]="rowActions" (rowAction)="onRow($event)" [rowSubject]="subjectOf"
[highlight]="isUnread" [initialSort]="{ key: 'date', direction: 'desc' }" [pageSize]="25">
<ng-template jtGridCell="flag" let-row>
<button type="button" class="align-middle" [class]="row.flagged ? 'text-yellow-400' : 'text-slate-600 hover:text-yellow-300'"
[attr.aria-label]="row.flagged ? 'Remove flag' : 'Flag'" [attr.aria-pressed]="row.flagged" (click)="toggleFlag(row)">
<mat-icon class="!text-xl">{{ row.flagged ? 'star' : 'star_border' }}</mat-icon>
</button>
</ng-template>
<ng-template jtGridCell="who" let-row>
<div class="max-w-[220px] truncate" [class.font-bold]="!row.read" [class.text-white]="!row.read">
{{ who(row) }}
</div>
</ng-template>
<ng-template jtGridCell="subject" let-row>
<a [routerLink]="link(row)" class="block max-w-[480px] group">
<span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read">
@if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> }
{{ row.subject || '(no subject)' }}
</span>
<span class="truncate block text-xs text-slate-500">{{ row.body }}</span>
</a>
</ng-template>
<ng-template jtGridCell="files" let-row>
@if (row.attachmentCount) {
<span class="text-cyan-300 text-xs font-mono whitespace-nowrap" [attr.aria-label]="row.attachmentCount + ' attachments'">
<mat-icon class="!text-base">attach_file</mat-icon>{{ row.attachmentCount }}
</span>
}
</ng-template>
<ng-template jtGridCell="date" let-row>
<span class="font-mono text-xs whitespace-nowrap text-slate-400" [title]="row.receivedAt | date: 'medium'">
{{ shortDate(row.receivedAt) }}
</span>
</ng-template>
<ng-template jtGridCell="size" let-row>
<span class="font-mono text-xs text-slate-500 whitespace-nowrap">{{ size(row) }}</span>
</ng-template>
</jt-grid>
</section>
`
})
export class FolderPage {
readonly id = input.required<string>();
readonly q = input<string | undefined>();
private api = inject(ApiService);
private state = inject(MailStateService);
private bulk = inject(JtGridBulkService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
private router = inject(Router);
readonly messages = signal<Message[]>([]);
readonly loading = signal(false);
readonly error = signal('');
readonly folderId = computed(() => Number(this.id()));
readonly folder = computed(() => this.state.folder(this.folderId()));
readonly title = computed(() => this.folder() ? folderLabel(this.folder()!) : 'Folder');
readonly icon = computed(() => this.folder() ? folderIcon(this.folder()!) : 'folder');
readonly kind = computed(() => this.folder()?.system ? this.folder()!.name : 'own');
readonly outgoing = computed(() => this.kind() === 'Sent' || this.kind() === 'Drafts');
readonly canEmpty = computed(() => this.kind() === 'Trash' || this.kind() === 'Spam');
readonly unread = computed(() => this.messages().filter(m => !m.read).length);
readonly columns = computed<JtGridColumn<Message>[]>(() => [
{ key: 'flag', label: 'Flag', hiddenLabel: true, sort: m => m.flagged },
{ key: 'who', label: this.outgoing() ? 'To' : 'From', sort: m => this.who(m).toLowerCase() },
{ key: 'subject', label: 'Subject', sort: m => (m.subject || '').toLowerCase() },
{ key: 'files', label: 'Attachments', hiddenLabel: true, sort: m => m.attachmentCount },
{ key: 'date', label: 'Date', sort: m => m.receivedAt, align: 'end' },
{ key: 'size', label: 'Size', sort: m => this.bytes(m), align: 'end' }
]);
readonly bulkActions = computed<RowAction[]>(() => {
const here = this.folderId();
const moves = this.state.folders()
.filter(f => f.id !== here && f.name !== 'Drafts' && f.name !== 'Sent')
.map(f => ({ value: 'move:' + f.id, label: 'To ' + folderLabel(f), icon: f.system && f.name === 'Spam' ? 'report' : 'drive_file_move' }));
return [
{ value: 'read', label: 'Mark read', icon: 'drafts' },
{ value: 'unread', label: 'Mark unread', icon: 'mark_email_unread' },
{ value: 'flag', label: 'Flag', icon: 'star' },
...moves.filter(m => !m.label.endsWith('Trash')),
{ value: 'delete', label: this.kind() === 'Trash' ? 'Delete forever' : 'Delete', icon: 'delete', danger: true }
];
});
readonly byId = (m: Message) => m.id;
readonly isUnread = (m: Message) => !m.read;
readonly subjectOf = (m: Message) => m.subject || '(no subject)';
readonly rowActions = (m: Message): RowAction[] => {
const draft = this.kind() === 'Drafts';
const spam = this.kind() === 'Spam';
return [
draft ? { value: 'edit', label: 'Edit draft', icon: 'edit', inline: true }
: { value: 'reply', label: 'Reply', icon: 'reply', inline: true },
...(draft ? [] : [{ value: 'forward', label: 'Forward', icon: 'forward' }]),
{ value: m.read ? 'unread' : 'read', label: m.read ? 'Mark unread' : 'Mark read', icon: m.read ? 'mark_email_unread' : 'drafts' },
...(this.outgoing() ? [] : [spam ? { value: 'notspam', label: 'Not spam', icon: 'inbox' } : { value: 'spam', label: 'Spam', icon: 'report' }]),
{ value: 'delete', label: this.kind() === 'Trash' ? 'Delete forever' : 'Delete', icon: 'delete', danger: true }
];
};
constructor() {
effect(() => {
this.folderId();
this.q();
this.load();
});
const timer = setInterval(() => { if (!this.q() && !document.hidden) this.load(true); }, 30000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
}
load(quiet = false): void {
const id = this.folderId();
if (!id) return;
if (!quiet) this.loading.set(true);
const q = this.q()?.trim();
(q ? this.api.search(id, q) : this.api.messages(id)).subscribe({
next: list => { this.messages.set(list); this.loading.set(false); this.error.set(''); },
error: e => { this.loading.set(false); if (!quiet) this.error.set(errorText(e)); }
});
}
who(m: Message): string {
return this.outgoing() ? (m.recipientEmail || '(no recipient)') : (m.senderName || m.senderEmail);
}
link(m: Message): unknown[] {
return this.kind() === 'Drafts' ? ['/compose', { draft: m.id }] : ['/message', m.id];
}
bytes(m: Message): number { return m.messageSize ?? 0; }
size(m: Message): string { return formatBytes(m.messageSize); }
shortDate(value: string): string {
const date = new Date(value);
const now = new Date();
return date.toDateString() === now.toDateString()
? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: date.toLocaleDateString([], { day: 'numeric', month: 'short', ...(date.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}) });
}
toggleFlag(m: Message): void {
this.patch(m, { flagged: !m.flagged });
}
onRow(e: JtGridRowEvent<Message>): void {
const m = e.row;
switch (e.action) {
case 'edit': this.router.navigate(['/compose', { draft: m.id }]); break;
case 'reply': this.router.navigate(['/compose', { reply: m.id }]); break;
case 'forward': this.router.navigate(['/compose', { forward: m.id }]); break;
case 'read': case 'unread':
this.patch(m, { read: e.action === 'read' });
break;
case 'spam': this.moveTo([m], this.state.system('Spam')?.id, 'Moved to Spam'); break;
case 'notspam': this.moveTo([m], this.state.inbox()?.id, 'Moved to Inbox'); break;
case 'delete':
if (this.kind() === 'Trash') {
this.dialogs.confirm('Delete forever?', `"${this.subjectOf(m)}" cannot be restored.`, 'Delete', true)
.subscribe(ok => ok !== null && this.remove([m]));
} else {
this.remove([m]);
}
break;
}
}
onBulk(e: JtGridBulkEvent<Message>): void {
const action = e.action;
if (action.startsWith('move:')) {
const target = this.state.folder(Number(action.slice(5)));
this.runBulk(e.rows, 'To ' + (target ? folderLabel(target) : 'folder'), m => this.api.updateMessage(m.id, { folderId: target!.id }));
return;
}
switch (action) {
case 'read': this.runBulk(e.rows, 'Mark read', m => this.api.updateMessage(m.id, { read: true })); break;
case 'unread': this.runBulk(e.rows, 'Mark unread', m => this.api.updateMessage(m.id, { read: false })); break;
case 'flag': this.runBulk(e.rows, 'Flag', m => this.api.updateMessage(m.id, { flagged: true })); break;
case 'delete':
this.runBulk(e.rows, this.kind() === 'Trash' ? 'Delete forever' : 'Delete', m => this.api.deleteMessage(m.id),
this.kind() === 'Trash' ? `Delete ${e.rows.length} messages forever? They cannot be restored.` : `Move ${e.rows.length} messages to the Trash?`);
break;
}
}
empty(): void {
const folder = this.folder();
if (!folder) return;
this.dialogs.confirm(`Empty ${this.title()}?`, `All ${this.messages().length} messages are deleted forever.`, 'Empty', true)
.subscribe(ok => {
if (ok === null) return;
this.api.emptyFolder(folder.id).subscribe({
next: r => { this.toasts.ok(`${r.deleted} messages deleted`); this.afterChange(); },
error: e => this.toasts.error(errorText(e))
});
});
}
private runBulk(rows: Message[], label: string, run: (m: Message) => Observable<unknown>, confirm?: string): void {
this.bulk.run({ rows, label, confirm, danger: !!confirm, run }).subscribe(() => this.afterChange());
}
private moveTo(rows: Message[], folderId: number | undefined, done: string): void {
if (!folderId) return;
forkJoin(rows.map(m => this.api.updateMessage(m.id, { folderId }))).subscribe({
next: () => { this.toasts.ok(done); this.afterChange(); },
error: e => this.toasts.error(errorText(e))
});
}
private remove(rows: Message[]): void {
forkJoin(rows.length ? rows.map(m => this.api.deleteMessage(m.id)) : [of(null)]).subscribe({
next: () => { this.toasts.ok(this.kind() === 'Trash' ? 'Deleted forever' : 'Moved to Trash'); this.afterChange(); },
error: e => this.toasts.error(errorText(e))
});
}
private patch(m: Message, change: { read?: boolean; flagged?: boolean }): void {
this.api.updateMessage(m.id, change).subscribe({
next: updated => {
this.messages.update(list => list.map(x => x.id === m.id ? { ...x, read: updated.read, flagged: updated.flagged } : x));
if (change.read !== undefined) this.state.refreshFolders();
},
error: e => this.toasts.error(errorText(e))
});
}
private afterChange(): void {
this.load(true);
this.state.refreshFolders();
this.state.refreshProfile();
}
}