feat: P2.2 Create Appointment from Email + P2.4 Calendar Dashlet + P2.12 Action Wheel + P2.14 Share Files
- P2.2: 'Create Appointment' button in email viewer → pre-fills event modal with subject, body, participants, date. calendar-store newEventPrefill state. - P2.4: MiniCalendarDashlet in sidebar bottom — month grid with event dots, day click navigates to calendar. Collapsible, respect firstDayOfWeek. - P2.12: Custom radial menu (components/ui/radial-menu.tsx) — circular SVG menu with keyboard nav, animations. Wired into email-list, contact-list, file-browser, calendar-month-view right-click handlers. - P2.14: 'Send as Attachment' button in file browser — opens compose tab with selected files pre-attached via Pro tab store.
This commit is contained in:
@@ -538,17 +538,19 @@ export function EmailComposer({
|
||||
// requests with the same draftId. See bug #303.
|
||||
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
if (replyTo?.attachments?.length) {
|
||||
let atts = replyTo.attachments;
|
||||
if (mode === 'forward') {
|
||||
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||
// (matches the viewer's hideInlineImageAttachments logic).
|
||||
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
atts = atts.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')));
|
||||
}
|
||||
return atts.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { Reply, ReplyAll, Forward, Star, Archive, FolderOpen } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
@@ -141,6 +143,101 @@ export function EmailList({
|
||||
const contextMenuEmail = contextMenu.data
|
||||
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||
: null;
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuEmail, setRadialMenuEmail] = useState<Email | null>(null);
|
||||
|
||||
const openRadialMenu = useCallback((e: React.MouseEvent, email: Email) => {
|
||||
e.preventDefault();
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuEmail(email);
|
||||
setRadialMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuEmail) return [];
|
||||
const email = radialMenuEmail;
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
|
||||
const act = (fn?: (email: Email) => void) => fn ? () => { fn(email); } : undefined;
|
||||
|
||||
const items: RadialMenuItem[] = [];
|
||||
|
||||
if (onReply) {
|
||||
items.push({
|
||||
id: "reply",
|
||||
icon: <Reply className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply"),
|
||||
onClick: () => { act(onReply)!(); },
|
||||
});
|
||||
}
|
||||
if (onReplyAll) {
|
||||
items.push({
|
||||
id: "reply-all",
|
||||
icon: <ReplyAll className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply_all"),
|
||||
onClick: () => { act(onReplyAll)!(); },
|
||||
});
|
||||
}
|
||||
if (onForward) {
|
||||
items.push({
|
||||
id: "forward",
|
||||
icon: <Forward className="w-5 h-5" />,
|
||||
label: t("../context_menu.forward"),
|
||||
onClick: () => { act(onForward)!(); },
|
||||
});
|
||||
}
|
||||
if (onToggleStar) {
|
||||
items.push({
|
||||
id: "star",
|
||||
icon: <Star className="w-5 h-5" fill={isStarred ? "currentColor" : "none"} />,
|
||||
label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"),
|
||||
onClick: () => { act(onToggleStar)!(); },
|
||||
});
|
||||
}
|
||||
if (onMarkAsRead) {
|
||||
items.push({
|
||||
id: "mark-read",
|
||||
icon: isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />,
|
||||
label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"),
|
||||
onClick: () => { onMarkAsRead(email, !isUnread); },
|
||||
});
|
||||
}
|
||||
if (onArchive) {
|
||||
items.push({
|
||||
id: "archive",
|
||||
icon: <Archive className="w-5 h-5" />,
|
||||
label: t("../context_menu.archive"),
|
||||
onClick: () => { act(onArchive)!(); },
|
||||
});
|
||||
}
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("../context_menu.delete"),
|
||||
onClick: () => { act(onDelete)!(); },
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
if (onMoveToMailbox) {
|
||||
items.push({
|
||||
id: "move",
|
||||
icon: <FolderOpen className="w-5 h-5" />,
|
||||
label: t("../context_menu.move_to"),
|
||||
onClick: () => { openContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: radialMenuPos.x, clientY: radialMenuPos.y } as React.MouseEvent, email); },
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [radialMenuEmail, radialMenuPos, t, onReply, onReplyAll, onForward, onToggleStar, onMarkAsRead, onArchive, onDelete, onMoveToMailbox, openContextMenu]);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -549,7 +646,7 @@ export function EmailList({
|
||||
onEmailSelect?.(email);
|
||||
}}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onContextMenu={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
@@ -581,6 +678,14 @@ export function EmailList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
@@ -88,6 +89,8 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
@@ -703,6 +706,9 @@ export function EmailViewer({
|
||||
const isScheduled = email?.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const createAppointmentVisible = !isScheduled && !isDraft && calendarEnabled && !!email;
|
||||
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
@@ -1029,6 +1035,34 @@ export function EmailViewer({
|
||||
const { isMobile: isMobileDevice } = useDeviceDetection();
|
||||
const router = useRouter();
|
||||
|
||||
const handleCreateAppointment = useCallback(() => {
|
||||
if (!email) return;
|
||||
const subject = email.subject ? `Re: ${email.subject}` : "";
|
||||
const body = email.htmlBody?.[0]?.partId
|
||||
? email.bodyValues?.[email.htmlBody[0].partId]?.value || ""
|
||||
: "";
|
||||
const participants: { name?: string; email: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addParticipant = (p?: { name?: string; email?: string }) => {
|
||||
if (!p?.email) return;
|
||||
const normalized = p.email.toLowerCase();
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
participants.push({ name: p.name, email: p.email });
|
||||
}
|
||||
};
|
||||
if (email.from) email.from.forEach(addParticipant);
|
||||
if (email.to) email.to.forEach(addParticipant);
|
||||
if (email.cc) email.cc.forEach(addParticipant);
|
||||
useCalendarStore.getState().setNewEventPrefill({
|
||||
title: subject,
|
||||
description: body,
|
||||
participants,
|
||||
date: email.receivedAt,
|
||||
});
|
||||
router.push('/calendar');
|
||||
}, [email, router]);
|
||||
|
||||
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
||||
if (isMobileDevice) {
|
||||
// No room for a sidebar on mobile - send the user to the contacts page
|
||||
@@ -2909,6 +2943,20 @@ export function EmailViewer({
|
||||
<Forward className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
||||
</Button>
|
||||
{createAppointmentVisible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCreateAppointment}
|
||||
data-overflow-item
|
||||
data-overflow-priority="3.5"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('create_appointment')}
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('create_appointment')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user