feat: enhance email selection with checkbox functionality and improve session handling
This commit is contained in:
@@ -22,7 +22,7 @@ interface EmailListItemProps {
|
|||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
@@ -80,6 +80,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectRangeEmails(email.id);
|
selectRangeEmails(email.id);
|
||||||
} else {
|
} else {
|
||||||
|
if (selectedEmailIds.size > 0) clearSelection();
|
||||||
onClick?.();
|
onClick?.();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle } from "lucide-react";
|
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function ThreadEmailItem({
|
|||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
|
|
||||||
const { dragHandlers, isDragging } = useEmailDrag({
|
const { dragHandlers, isDragging } = useEmailDrag({
|
||||||
@@ -38,6 +38,11 @@ export function ThreadEmailItem({
|
|||||||
onContextMenu?.(e, email);
|
onContextMenu?.(e, email);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleEmailSelection(email.id);
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -46,6 +51,7 @@ export function ThreadEmailItem({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectRangeEmails(email.id);
|
selectRangeEmails(email.id);
|
||||||
} else {
|
} else {
|
||||||
|
if (selectedEmailIds.size > 0) clearSelection();
|
||||||
onClick?.();
|
onClick?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -69,6 +75,23 @@ export function ThreadEmailItem({
|
|||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={handleCheckboxClick}
|
||||||
|
className={cn(
|
||||||
|
"p-1 rounded mt-0.5 flex-shrink-0 transition-all duration-200",
|
||||||
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
|
"active:scale-95",
|
||||||
|
isChecked && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isChecked ? (
|
||||||
|
<CheckSquare className="w-3.5 h-3.5 animate-in zoom-in-50 duration-200" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-3.5 h-3.5 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare } from "lucide-react";
|
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square } from "lucide-react";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -40,7 +40,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
|
|
||||||
@@ -57,6 +57,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
sourceMailboxId: selectedMailbox,
|
sourceMailboxId: selectedMailbox,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleEmailSelection(email.id);
|
||||||
|
};
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
onContextMenu?.(e, email);
|
onContextMenu?.(e, email);
|
||||||
};
|
};
|
||||||
@@ -69,6 +74,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectRangeEmails(email.id);
|
selectRangeEmails(email.id);
|
||||||
} else {
|
} else {
|
||||||
|
if (selectedEmailIds.size > 0) clearSelection();
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -96,6 +102,23 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 px-3 py-3">
|
<div className="flex items-start gap-3 px-3 py-3">
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={handleCheckboxClick}
|
||||||
|
className={cn(
|
||||||
|
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||||
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
|
"active:scale-95",
|
||||||
|
isChecked && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isChecked ? (
|
||||||
|
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||||
@@ -193,7 +216,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
||||||
|
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
|
|
||||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||||
email: latestEmail,
|
email: latestEmail,
|
||||||
@@ -227,6 +250,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
const emailsToShow = expandedEmails || thread.emails;
|
const emailsToShow = expandedEmails || thread.emails;
|
||||||
|
|
||||||
|
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Toggle selection for all emails in this thread
|
||||||
|
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
|
||||||
|
const newSelection = new Set(selectedEmailIds);
|
||||||
|
thread.emails.forEach(em => {
|
||||||
|
if (allSelected) {
|
||||||
|
newSelection.delete(em.id);
|
||||||
|
} else {
|
||||||
|
newSelection.add(em.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
|
||||||
|
};
|
||||||
|
|
||||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -249,6 +287,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
if (target.closest('[data-expand-toggle]')) {
|
if (target.closest('[data-expand-toggle]')) {
|
||||||
onToggleExpand();
|
onToggleExpand();
|
||||||
} else {
|
} else {
|
||||||
|
if (selectedEmailIds.size > 0) clearSelection();
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
onToggleExpand();
|
onToggleExpand();
|
||||||
}
|
}
|
||||||
@@ -283,6 +322,23 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 px-3 py-3">
|
<div className="flex items-start gap-3 px-3 py-3">
|
||||||
|
{/* Checkbox for thread selection */}
|
||||||
|
<button
|
||||||
|
onClick={handleThreadCheckboxClick}
|
||||||
|
className={cn(
|
||||||
|
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||||
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
|
"active:scale-95",
|
||||||
|
isChecked && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isChecked ? (
|
||||||
|
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<button
|
<button
|
||||||
data-expand-toggle
|
data-expand-toggle
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function createDragPreview(count: number): HTMLElement {
|
|||||||
left: 0;
|
left: 0;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background-color: var(--color-primary, #3b82f6);
|
background-color: var(--color-primary, #3b82f6);
|
||||||
color: white;
|
color: var(--color-primary-foreground, #ffffff);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
+60
-7
@@ -116,6 +116,8 @@ export class JMAPClient {
|
|||||||
private eventSource: EventSource | null = null;
|
private eventSource: EventSource | null = null;
|
||||||
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||||
private lastStates: AccountStates = {};
|
private lastStates: AccountStates = {};
|
||||||
|
private reconnecting = false;
|
||||||
|
private connectionChangeCallback: ((connected: boolean) => void) | null = null;
|
||||||
|
|
||||||
constructor(serverUrl: string, username: string, password: string) {
|
constructor(serverUrl: string, username: string, password: string) {
|
||||||
this.serverUrl = serverUrl.replace(/\/$/, '');
|
this.serverUrl = serverUrl.replace(/\/$/, '');
|
||||||
@@ -143,20 +145,64 @@ export class JMAPClient {
|
|||||||
|
|
||||||
private async authenticatedFetch(url: string, init?: Parameters<typeof fetch>[1]): Promise<Response> {
|
private async authenticatedFetch(url: string, init?: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||||
const headers = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
const headers = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||||||
let response = await fetch(url, { ...init, headers });
|
let response: Response;
|
||||||
|
|
||||||
if (response.status === 401 && this.authMode === 'bearer' && this.onTokenRefresh) {
|
try {
|
||||||
const newToken = await this.onTokenRefresh();
|
response = await fetch(url, { ...init, headers });
|
||||||
if (newToken) {
|
} catch (error) {
|
||||||
this.updateAccessToken(newToken);
|
// Network error: retry once after brief delay (transient proxy/connection issues)
|
||||||
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
if (this.reconnecting) throw error;
|
||||||
response = await fetch(url, { ...init, headers: retryHeaders });
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
response = await fetch(url, { ...init, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
if (this.authMode === 'bearer' && this.onTokenRefresh) {
|
||||||
|
const newToken = await this.onTokenRefresh();
|
||||||
|
if (newToken) {
|
||||||
|
this.updateAccessToken(newToken);
|
||||||
|
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||||||
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
||||||
|
}
|
||||||
|
} else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) {
|
||||||
|
// JMAP session may have expired — re-establish and retry once
|
||||||
|
this.reconnecting = true;
|
||||||
|
try {
|
||||||
|
await this.refreshSession();
|
||||||
|
this.connectionChangeCallback?.(true);
|
||||||
|
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||||||
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
||||||
|
} catch {
|
||||||
|
// Session refresh failed — return original 401 response
|
||||||
|
} finally {
|
||||||
|
this.reconnecting = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshSession(): Promise<void> {
|
||||||
|
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
||||||
|
const response = await fetch(sessionUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': this.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Session refresh failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await response.json();
|
||||||
|
this.rewriteSessionUrls(session);
|
||||||
|
this.session = session;
|
||||||
|
this.capabilities = session.capabilities || {};
|
||||||
|
this.apiUrl = session.apiUrl;
|
||||||
|
this.downloadUrl = session.downloadUrl;
|
||||||
|
this.accounts = session.accounts || {};
|
||||||
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
||||||
|
|
||||||
@@ -213,10 +259,13 @@ export class JMAPClient {
|
|||||||
this.pingInterval = setInterval(async () => {
|
this.pingInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
await this.ping();
|
await this.ping();
|
||||||
|
this.connectionChangeCallback?.(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Keep-alive ping failed:', error);
|
console.error('Keep-alive ping failed:', error);
|
||||||
|
this.connectionChangeCallback?.(false);
|
||||||
try {
|
try {
|
||||||
await this.reconnect();
|
await this.reconnect();
|
||||||
|
this.connectionChangeCallback?.(true);
|
||||||
} catch (reconnectError) {
|
} catch (reconnectError) {
|
||||||
console.error('Reconnection failed:', reconnectError);
|
console.error('Reconnection failed:', reconnectError);
|
||||||
}
|
}
|
||||||
@@ -2293,6 +2342,10 @@ export class JMAPClient {
|
|||||||
this.pollingStates = {};
|
this.pollingStates = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onConnectionChange(callback: (connected: boolean) => void): void {
|
||||||
|
this.connectionChangeCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
onStateChange(callback: (change: StateChange) => void): void {
|
onStateChange(callback: (change: StateChange) => void): void {
|
||||||
this.stateChangeCallback = callback;
|
this.stateChangeCallback = callback;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user