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: ` {{ icon() }} {{ title() }} @if (q()) { Search “{{ q() }}”: {{ messages().length }} found } @else { {{ messages().length }} messages · {{ unread() }} unread } @if (q()) { close Clear search } refresh @if (canEmpty()) { delete_forever Empty {{ title() }} } @if (error()) { {{ error() }} } {{ row.flagged ? 'star' : 'star_border' }} {{ who(row) }} @if (!row.read) { } {{ row.subject || '(no subject)' }} {{ row.body }} @if (row.attachmentCount) { attach_file{{ row.attachmentCount }} } {{ shortDate(row.receivedAt) }} {{ size(row) }} ` }) export class FolderPage { readonly id = input.required(); readonly q = input(); 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([]); 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[]>(() => [ { 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(() => { 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): 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): 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, 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(); } }
@if (q()) { Search “{{ q() }}”: {{ messages().length }} found } @else { {{ messages().length }} messages · {{ unread() }} unread }
{{ error() }}