mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: Display Name / Alias for IMAP Accounts #306
This commit is contained in:
@@ -23,6 +23,7 @@ import { PaginatedResponse } from "..";
|
||||
export interface MinimalAccount {
|
||||
id: number;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const minimal_account_list = async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface EmailEnvelope {
|
||||
account_id: number;
|
||||
mailbox_id: number;
|
||||
account_email: string;
|
||||
account_name?: string;
|
||||
mailbox_name: string;
|
||||
uid: number;
|
||||
subject: string;
|
||||
|
||||
@@ -25,6 +25,11 @@ import LongText from '@/components/long-text';
|
||||
import { getToken } from '@/stores/authStore';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
import {
|
||||
Tooltip as TooltipUI,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
interface DailyActivity {
|
||||
date: string;
|
||||
@@ -128,6 +133,12 @@ export default function MailArchiveDashboard() {
|
||||
return account ? account.id : null;
|
||||
};
|
||||
|
||||
const getAccountNameByEmail = (email: string): string | null => {
|
||||
if (!minimalList) return null;
|
||||
const account = minimalList.find(a => a.email === email);
|
||||
return account?.name || null;
|
||||
};
|
||||
|
||||
const handleQuickSearch = (filter: Record<string, any>) => {
|
||||
navigate({
|
||||
to: '/search',
|
||||
@@ -519,16 +530,30 @@ export default function MailArchiveDashboard() {
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<LongText className="max-w-[180px] md:max-w-[160px] lg:max-w-[200px] xl:max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors"
|
||||
>
|
||||
{acc.key}
|
||||
</button>
|
||||
{(() => {
|
||||
const name = getAccountNameByEmail(acc.key);
|
||||
const btn = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleQuickSearch({ account_ids: [getAccountIdByEmail(acc.key) || 0] })
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors"
|
||||
>
|
||||
{name || acc.key}
|
||||
</button>
|
||||
);
|
||||
if (name) {
|
||||
return (
|
||||
<TooltipUI>
|
||||
<TooltipTrigger asChild>{btn}</TooltipTrigger>
|
||||
<TooltipContent side="top">{acc.key}</TooltipContent>
|
||||
</TooltipUI>
|
||||
);
|
||||
}
|
||||
return btn;
|
||||
})()}
|
||||
</LongText>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -83,6 +88,7 @@ export function AccountPopover() {
|
||||
.filter(a =>
|
||||
!q ||
|
||||
a.email.toLowerCase().includes(q) ||
|
||||
a.name?.toLowerCase().includes(q) ||
|
||||
String(a.id).includes(q)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
@@ -184,9 +190,22 @@ export function AccountPopover() {
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">
|
||||
{account.email}
|
||||
</span>
|
||||
{account.name ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate">
|
||||
{account.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{account.email}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{account.email}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
#{account.id}
|
||||
</span>
|
||||
|
||||
@@ -89,9 +89,9 @@ export function MailListTable({
|
||||
accessorKey: "source",
|
||||
header: t('search.source'),
|
||||
cell: ({ row }) => {
|
||||
const { from, account_email, mailbox_name, account_id, mailbox_id } = row.original;
|
||||
const { from, account_email, account_name, mailbox_name, account_id, mailbox_id } = row.original;
|
||||
const { setFilter } = useSearchMessages();
|
||||
const accountPrefix = account_email.split('@')[0];
|
||||
const accountPrefix = account_name ?? account_email.split('@')[0];//https://github.com/rustmailer/bichon/issues/306
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-1.5 min-w-0 group">
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useSearchContext } from "./context"
|
||||
import { MailBulkActions } from "./bulk-actions"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
onEnvelopeChanged: (envelope: EmailEnvelope) => void
|
||||
}
|
||||
|
||||
export function MailList({
|
||||
items,
|
||||
isLoading,
|
||||
onEnvelopeChanged
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const total = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0);
|
||||
|
||||
if (total === items.length && items.length > 0) {
|
||||
setSelected(new Map());
|
||||
} else {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev);
|
||||
for (const item of items) {
|
||||
const set = new Set(next.get(item.account_id) || []);
|
||||
set.add(item.id);
|
||||
next.set(item.account_id, set);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: string) => {
|
||||
setToDelete(prev => {
|
||||
const next = new Map(prev);
|
||||
const set = new Set(next.get(accountId) || []);
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId);
|
||||
if (set.size === 0) next.delete(accountId);
|
||||
else next.set(accountId, set);
|
||||
} else {
|
||||
set.add(mailId);
|
||||
next.set(accountId, set);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelected = (accountId: number, mailId: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev);
|
||||
const set = new Set(next.get(accountId) || []);
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId);
|
||||
if (set.size === 0) next.delete(accountId);
|
||||
else next.set(accountId, set);
|
||||
} else {
|
||||
set.add(mailId);
|
||||
next.set(accountId, set);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const totalSelected = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0);
|
||||
|
||||
const hasSelected = (accountId: number, mailId: string) => {
|
||||
return selected.get(accountId)?.has(mailId) ?? false;
|
||||
}
|
||||
|
||||
const handleDelete = (envelope: EmailEnvelope) => {
|
||||
setToDelete(new Map());
|
||||
toggleToDelete(envelope.account_id, envelope.id)
|
||||
setOpen("delete")
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-2.5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
? true
|
||||
: totalSelected > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalSelected > 0
|
||||
? `${t('search.bulkActions.selected', { count: totalSelected })}`
|
||||
: t('common.selectAll')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((item, index) => {
|
||||
const hasAttachments = item.regular_attachment_count > 0
|
||||
const isSelectedRow = currentEnvelope?.id === item.id
|
||||
const isChecked = hasSelected(item.account_id, item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"hover:bg-accent/50",
|
||||
isSelectedRow && "bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onEnvelopeChanged(item)
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleSelected(item.account_id, item.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
|
||||
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
|
||||
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.from}</p>
|
||||
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
|
||||
{item.subject}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60">
|
||||
<span className="truncate">{item.account_email}</span>
|
||||
<span className="scale-75 opacity-50">•</span>
|
||||
<span className="font-medium text-primary/70">{item.mailbox_name}</span>
|
||||
</div>
|
||||
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
|
||||
{item.subject}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mt-0.25">
|
||||
{item.tags?.map((tag, i) => (
|
||||
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
|
||||
{hasAttachments && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
<span>{item.regular_attachment_count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="hidden md:inline">{formatBytes(item.size)}</span>
|
||||
|
||||
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("edit-tags");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('search.editTag')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("restore");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('restore_message.restore_to_imap')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="ml-2 h-3.5 w-3.5" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{totalSelected > 0 && <MailBulkActions />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user