diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index a7cf5e866..e4eeeb19e 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -1,5 +1,7 @@ "use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Email } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; import type { HoverAction } from "@/stores/settings-store"; @@ -7,6 +9,8 @@ import { cn } from "@/lib/utils"; import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; import { useIsMobile } from "@/hooks/use-media-query"; +import { TagPicker } from "@/components/email/tag-picker"; +import { getEmailTagIds } from "@/lib/thread-utils"; interface EmailHoverActionsProps { email: Email; @@ -69,6 +73,10 @@ const CORNER_CLASSES = { 'bottom-left': 'bottom-1 left-1', } as const; +/** Roughly the picker's height; used to flip it above the row near the viewport bottom. */ +const TAG_PICKER_MAX_HEIGHT = 320; +const TAG_PICKER_WIDTH = 224; + export function EmailHoverActions({ email, backgroundClassName = "bg-muted", @@ -88,8 +96,43 @@ export function EmailHoverActions({ const t = useTranslations("settings.email_behavior.hover_actions"); const isMobile = useIsMobile(); + // The tag quick-action opens the same picker the context menu and the reading + // pane use. It is portalled to the body because every row sits inside a + // `transform`ed virtual item, which would make a `fixed` child scroll with the + // list and clip it at the scroller's edge. + const [tagPickerPos, setTagPickerPos] = useState<{ top: number; left: number } | null>(null); + const pickerRef = useRef(null); + const tagButtonRef = useRef(null); + const closeTagPicker = useCallback(() => setTagPickerPos(null), []); + + useEffect(() => { + if (!tagPickerPos) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + // The anchor is left to its own click handler, which toggles the picker + // shut - closing here first would let that click reopen it. + if (tagButtonRef.current?.contains(target)) return; + if (!pickerRef.current?.contains(target)) closeTagPicker(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closeTagPicker(); + }; + // The anchor moves with the list, so scrolling dismisses rather than chases it. + document.addEventListener("pointerdown", onPointerDown, true); + document.addEventListener("keydown", onKeyDown); + window.addEventListener("scroll", closeTagPicker, true); + window.addEventListener("resize", closeTagPicker); + return () => { + document.removeEventListener("pointerdown", onPointerDown, true); + document.removeEventListener("keydown", onKeyDown); + window.removeEventListener("scroll", closeTagPicker, true); + window.removeEventListener("resize", closeTagPicker); + }; + }, [tagPickerPos, closeTagPicker]); + const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const currentTagIds = getEmailTagIds(email.keywords); const hoverBackgroundClassName = backgroundClassName; if (isMobile) return null; @@ -111,9 +154,28 @@ export function EmailHoverActions({ case "archive": onArchive?.(); break; - case "tag": - onSetTag?.(null); + case "tag": { + // Previously this cleared every tag on the message, which is a no-op on + // an untagged one - hence "clicking it does nothing". Open the picker. + if (!onSetTag) break; + tagButtonRef.current = e.currentTarget as HTMLElement; + const rect = e.currentTarget.getBoundingClientRect(); + setTagPickerPos((open) => + open + ? null + : { + top: + rect.bottom + TAG_PICKER_MAX_HEIGHT > window.innerHeight + ? Math.max(8, rect.top - TAG_PICKER_MAX_HEIGHT - 4) + : rect.bottom + 4, + left: Math.max( + 8, + Math.min(rect.left, window.innerWidth - TAG_PICKER_WIDTH - 8), + ), + }, + ); break; + } case "spam": if (isInJunk) onUndoSpam?.(); else onMarkAsSpam?.(); @@ -144,8 +206,13 @@ export function EmailHoverActions({ return (