Feature: pin emails to the top of the folder list
Outlook-Web-style pinning: a context-menu Pin/Unpin action stores a $pinned keyword on the message (plain IMAP-compatible flag, survives other clients), and pinned mails stay at the top of the folder list regardless of age, marked with a pin icon. Ordering is done server-side via the hasKeyword sort comparator (RFC 8621), applied consistently to the folder fetch, pagination and the push-refresh so page windows stay stable. The client-side safety sort in getEmails mirrors it, and sortThreadGroups keeps threads containing a pinned mail on top so the client-side thread grouping does not undo the order. The new-mail notification in refreshCurrentMailbox now checks the first non-pinned entry: with pinned mails on top, the newest mail is no longer at index 0 and arrivals would never have notified. The toggle reuses the color-tag pathway (routed keyword write for unified views, in-place local patch), then refetches the first page so the mail floats or sinks immediately. Search and unified views keep their existing order. Pin/Unpin strings are added to all 21 locales.
This commit is contained in:
@@ -17,6 +17,8 @@ import {
|
||||
Mail,
|
||||
MailOpen,
|
||||
Star,
|
||||
Pin,
|
||||
PinOff,
|
||||
Trash2,
|
||||
Archive,
|
||||
FolderInput,
|
||||
@@ -59,6 +61,7 @@ interface EmailContextMenuProps {
|
||||
onForward?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onToggleStar?: () => void;
|
||||
onTogglePinned?: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
@@ -126,6 +129,7 @@ export function EmailContextMenu({
|
||||
onForward,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
@@ -149,6 +153,7 @@ export function EmailContextMenu({
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
@@ -352,6 +357,15 @@ export function EmailContextMenu({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
|
||||
{!showBatchActions && onTogglePinned && (
|
||||
<ContextMenuItem
|
||||
icon={isPinned ? PinOff : Pin}
|
||||
label={isPinned ? t("unpin") : t("pin")}
|
||||
onClick={() => handleAction(onTogglePinned)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Set tag submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -45,6 +45,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
@@ -217,6 +218,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
@@ -253,6 +255,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ interface EmailListProps {
|
||||
onForward?: (email: Email) => void;
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
onTogglePinned?: (email: Email) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
@@ -64,6 +65,7 @@ export function EmailList({
|
||||
onForward,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
@@ -551,6 +553,7 @@ export function EmailList({
|
||||
onForward={() => onForward?.(contextMenu.data!)}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -78,6 +78,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
@@ -264,6 +265,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
@@ -316,6 +318,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
@@ -443,7 +448,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
@@ -723,6 +728,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{hasPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
@@ -787,6 +793,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{hasStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user