diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 04d78eba..04d8b8b9 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1652,6 +1652,45 @@ export default function Home() { } }; + const handleTogglePinned = async (emailToPin: Email) => { + if (!client) return; + + try { + const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin; + const isPinned = email.keywords?.['$pinned'] === true; + // JMAP keywords are a set of present keys - drop the key to unpin + // rather than writing a false value. + const keywords = { ...email.keywords }; + if (isPinned) { + delete keywords['$pinned']; + } else { + keywords['$pinned'] = true; + } + + // Same unified-view routing as color tags: write to the email's own + // account via the login it is reachable through. (#281) + const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; + const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; + const pinClient = pinClientId + ? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client) + : client; + + await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId); + + // Patch in place so the icon flips immediately, then refetch the first + // page so the mail floats/sinks per the server's pinned-first sort. + // Skip the refetch where that sort does not apply (unified views) or + // where it would replace a tag-filtered list (refreshCurrentMailbox + // fetches by folder only). + setEmailKeywordsLocal(email.id, keywords); + if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) { + void refreshCurrentMailbox(client); + } + } catch (error) { + console.error("Failed to toggle pin:", error); + } + }; + const handleSetColorTag = async (emailId: string, color: string | null) => { if (!client) return; @@ -3042,6 +3081,9 @@ export default function Home() { await toggleStar(client, email.id); } }} + onTogglePinned={async (email) => { + await handleTogglePinned(email); + }} onDelete={async (email) => { await handleDelete(email); }} diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 53ae2eac..9a66f9b6 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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 && ( + handleAction(onTogglePinned)} + /> + )} + {/* Set tag submenu - only for single email */} {!showBatchActions && ( diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index faabb6a2..b29819fe 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -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
+ {isPinned && } {isStarred && } {isImportant && } {isAnswered && !isForwarded && } @@ -253,6 +255,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte {sender?.name || sender?.email || "Unknown"}
+ {isPinned && ( + + )} {isStarred && ( )} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 9c3ec0c7..cab95c82 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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)} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 1c92c090..69039503 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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( 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(
+ {isPinned && } {isStarred && } {isAnswered && !isForwarded && } {isForwarded && !isAnswered && } @@ -316,6 +318,9 @@ const SingleEmailItem = React.forwardRef( {sender?.name || sender?.email || "Unknown"}
+ {isPinned && ( + + )} {isStarred && ( )} @@ -443,7 +448,7 @@ export const ThreadListItem = React.forwardRef 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
+ {hasPinned && } {hasStarred && } {hasAnswered && !hasForwarded && } {hasForwarded && !hasAnswered && } @@ -787,6 +793,9 @@ export const ThreadListItem = React.forwardRef
+ {hasPinned && ( + + )} {hasStarred && ( )} diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index bde3a6b5..62cf41a2 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -116,37 +116,47 @@ describe('groupEmailsByThread', () => { }); describe('sortThreadGroups', () => { + const makeGroup = (threadId: string, receivedAt: string, hasPinned = false): ThreadGroup => ({ + threadId, + emails: [makeEmail({ receivedAt })], + latestEmail: makeEmail({ receivedAt }), + participantNames: ['A'], + hasUnread: false, + hasStarred: false, + hasPinned, + hasAttachment: false, + hasAnswered: false, + hasForwarded: false, + emailCount: 1, + }); + it('sorts groups by latestEmail.receivedAt descending', () => { - const groups: ThreadGroup[] = [ - { - threadId: 'old', - emails: [makeEmail({ receivedAt: '2024-01-01T00:00:00Z' })], - latestEmail: makeEmail({ receivedAt: '2024-01-01T00:00:00Z' }), - participantNames: ['A'], - hasUnread: false, - hasStarred: false, - hasAttachment: false, - hasAnswered: false, - hasForwarded: false, - emailCount: 1, - }, - { - threadId: 'new', - emails: [makeEmail({ receivedAt: '2024-06-01T00:00:00Z' })], - latestEmail: makeEmail({ receivedAt: '2024-06-01T00:00:00Z' }), - participantNames: ['B'], - hasUnread: false, - hasStarred: false, - hasAttachment: false, - hasAnswered: false, - hasForwarded: false, - emailCount: 1, - }, + const groups = [ + makeGroup('old', '2024-01-01T00:00:00Z'), + makeGroup('new', '2024-06-01T00:00:00Z'), ]; const sorted = sortThreadGroups(groups); expect(sorted[0].threadId).toBe('new'); expect(sorted[1].threadId).toBe('old'); }); + + it('keeps pinned threads on top regardless of date', () => { + const groups = [ + makeGroup('newest', '2024-06-01T00:00:00Z'), + makeGroup('old-pinned', '2024-01-01T00:00:00Z', true), + makeGroup('mid', '2024-03-01T00:00:00Z'), + ]; + const sorted = sortThreadGroups(groups); + expect(sorted.map(g => g.threadId)).toEqual(['old-pinned', 'newest', 'mid']); + }); + + it('detects hasPinned from the $pinned keyword', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { $seen: true } }), + makeEmail({ id: 'e2', keywords: { $seen: true, '$pinned': true } }), + ]; + expect(groupEmailsByThread(emails)[0].hasPinned).toBe(true); + }); }); describe('getThreadParticipants', () => { @@ -188,6 +198,7 @@ describe('mergeThreadEmails', () => { participantNames: ['Alice'], hasUnread: false, hasStarred: false, + hasPinned: false, hasAttachment: false, hasAnswered: false, hasForwarded: false, @@ -210,6 +221,7 @@ describe('mergeThreadEmails', () => { participantNames: ['Alice'], hasUnread: false, hasStarred: false, + hasPinned: false, hasAttachment: false, hasAnswered: false, hasForwarded: false, diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index df425485..4e94555e 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -151,12 +151,16 @@ export class DemoJMAPClient implements IJMAPClient { // ── Emails ──────────────────────────────────────────────────── - async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> { + async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, _hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }> { let filtered = this.data.emails; if (mailboxId) { filtered = filtered.filter(e => e.mailboxIds[mailboxId]); } - filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()); + const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0); + filtered.sort((a, b) => + pinRank(b) - pinRank(a) || + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); const total = filtered.length; const emails = filtered.slice(position, position + limit); return { emails, hasMore: position + limit < total, total }; diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index bb4ad31c..68c4ce1a 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -78,7 +78,9 @@ export interface IJMAPClient { deleteMailbox(mailboxId: string): Promise; // ── Emails ──────────────────────────────────────────────────── - getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; + // `pinnedFirst` sorts emails carrying the $pinned keyword to the top + // (server-side hasKeyword sort comparator, RFC 8621), then receivedAt desc. + getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; getEmailsInMailbox(mailboxId: string): Promise; getEmail(emailId: string, accountId?: string): Promise; getTagCounts(tagIds: string[]): Promise>; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d9773d71..eb37c02b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1053,7 +1053,7 @@ export class JMAPClient implements IJMAPClient { } } - async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string): Promise<{ emails: Email[], hasMore: boolean, total: number }> { + async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[], hasMore: boolean, total: number }> { try { const targetAccountId = accountId || this.accountId; const filter: { inMailbox?: string; hasKeyword?: string } = {}; @@ -1063,12 +1063,20 @@ export class JMAPClient implements IJMAPClient { if (hasKeyword) { filter.hasKeyword = hasKeyword; } + // Pinned-first uses the hasKeyword sort comparator (RFC 8621 §4.4.2); + // every page of a view must use the same sort or pagination tears. + const sort = pinnedFirst + ? [ + { property: "hasKeyword", keyword: "$pinned", isAscending: false }, + { property: "receivedAt", isAscending: false }, + ] + : [{ property: "receivedAt", isAscending: false }]; const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter, - sort: [{ property: "receivedAt", isAscending: false }], + sort, limit, position, calculateTotal: true, @@ -1087,7 +1095,10 @@ export class JMAPClient implements IJMAPClient { const emails = (getResponse.list || []) as Email[]; // Sort client-side as safety net - some servers may not honour // the query sort for large mailboxes without additional filters. + // Must mirror the query sort, or it would undo the pinned-first order. + const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0); emails.sort((a: Email, b: Email) => + pinRank(b) - pinRank(a) || new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() ); const total = queryResponse?.total || 0; diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index c57f80d3..fe5ca45c 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -217,6 +217,7 @@ export interface ThreadGroup { participantNames: string[];// Unique participant names hasUnread: boolean; // Any unread emails in thread hasStarred: boolean; // Any starred emails in thread + hasPinned: boolean; // Any pinned emails in thread ($pinned keyword) hasAttachment: boolean; // Any email has attachment hasAnswered: boolean; // Any email has been replied to hasForwarded: boolean; // Any email has been forwarded diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 45ced51d..41037492 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -44,9 +44,10 @@ export function groupEmailsByThread( // Collect unique participant names from all emails in thread const participantNames = getThreadParticipants(sortedEmails); - // Check for unread, starred, and attachments + // Check for unread, starred, pinned, and attachments const hasUnread = sortedEmails.some(e => !e.keywords?.$seen); const hasStarred = sortedEmails.some(e => e.keywords?.$flagged); + const hasPinned = sortedEmails.some(e => e.keywords?.['$pinned']); const hasAttachment = sortedEmails.some(e => e.hasAttachment); const hasAnswered = sortedEmails.some(e => e.keywords?.$answered); const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded); @@ -58,6 +59,7 @@ export function groupEmailsByThread( participantNames, hasUnread, hasStarred, + hasPinned, hasAttachment, hasAnswered, hasForwarded, @@ -70,10 +72,14 @@ export function groupEmailsByThread( /** * Sorts thread groups by their latest email's receivedAt date (newest first). + * Threads containing a pinned email ($pinned keyword) stay on top, mirroring + * the server-side pinned-first sort of the email list. */ export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] { return [...groups].sort( - (a, b) => new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime() + (a, b) => + (b.hasPinned ? 1 : 0) - (a.hasPinned ? 1 : 0) || + new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime() ); } @@ -136,6 +142,7 @@ export function mergeThreadEmails( const participantNames = getThreadParticipants(mergedEmails); const hasUnread = mergedEmails.some(e => !e.keywords?.$seen); const hasStarred = mergedEmails.some(e => e.keywords?.$flagged); + const hasPinned = mergedEmails.some(e => e.keywords?.['$pinned']); const hasAttachment = mergedEmails.some(e => e.hasAttachment); const hasAnswered = mergedEmails.some(e => e.keywords?.$answered); const hasForwarded = mergedEmails.some(e => e.keywords?.$forwarded); @@ -147,6 +154,7 @@ export function mergeThreadEmails( participantNames, hasUnread, hasStarred, + hasPinned, hasAttachment, hasAnswered, hasForwarded, diff --git a/locales/cs/common.json b/locales/cs/common.json index 32df1198..e4a1a398 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Označit jako nepřečtené", "star": "Označit hvězdičkou", "unstar": "Odebrat hvězdičku", + "pin": "Připnout", + "unpin": "Odepnout", "move_to": "Přesunout do...", "archive": "Archivovat", "delete": "Odstranit", diff --git a/locales/da/common.json b/locales/da/common.json index 6c21af91..591f978f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Markér som ulæst", "star": "Stjernemarkér", "unstar": "Fjern stjerne", + "pin": "Fastgør", + "unpin": "Frigør", "move_to": "Flyt til...", "archive": "Arkivér", "delete": "Slet", diff --git a/locales/de/common.json b/locales/de/common.json index 4712df5c..bfea795e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Als ungelesen markieren", "star": "Stern hinzufügen", "unstar": "Stern entfernen", + "pin": "Anheften", + "unpin": "Lösen", "move_to": "Verschieben nach...", "archive": "Archivieren", "delete": "Löschen", diff --git a/locales/en/common.json b/locales/en/common.json index aa9620e4..f540c8f8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Mark as Unread", "star": "Star", "unstar": "Unstar", + "pin": "Pin", + "unpin": "Unpin", "move_to": "Move to...", "archive": "Archive", "delete": "Delete", diff --git a/locales/es/common.json b/locales/es/common.json index d0977f0c..b06bb3e0 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Marcar como No Leído", "star": "Destacar", "unstar": "Quitar Destacado", + "pin": "Anclar", + "unpin": "Desanclar", "move_to": "Mover a...", "archive": "Archivar", "delete": "Eliminar", diff --git a/locales/fa/common.json b/locales/fa/common.json index 602177d6..59d49a94 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "علامت‌گذاری خوانده نشده", "star": "ستاره‌دار", "unstar": "حذف ستاره", + "pin": "سنجاق کردن", + "unpin": "برداشتن سنجاق", "move_to": "انتقال به...", "archive": "بایگانی", "delete": "حذف", diff --git a/locales/fr/common.json b/locales/fr/common.json index 0ed9f37d..8852dd5f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Marquer comme non lu", "star": "Marquer comme favori", "unstar": "Retirer des favoris", + "pin": "Épingler", + "unpin": "Désépingler", "move_to": "Déplacer vers...", "archive": "Archiver", "delete": "Supprimer", diff --git a/locales/hu/common.json b/locales/hu/common.json index 90968c71..f5ef30c8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Olvasatlannak jelölés", "star": "Csillagozás", "unstar": "Csillagozás megszüntetése", + "pin": "Rögzítés", + "unpin": "Rögzítés feloldása", "move_to": "Áthelyezés ide...", "archive": "Archiválás", "delete": "Törlés", diff --git a/locales/it/common.json b/locales/it/common.json index 7d6a17da..6642e481 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Segna come non letto", "star": "Aggiungi stella", "unstar": "Rimuovi stella", + "pin": "Fissa", + "unpin": "Non fissare più", "move_to": "Sposta in...", "archive": "Archivia", "delete": "Elimina", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3106458d..932df81c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "未読にする", "star": "スターを付ける", "unstar": "スターを外す", + "pin": "ピン留め", + "unpin": "ピン留めを外す", "move_to": "移動...", "archive": "アーカイブ", "delete": "削除", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9bef1adc..d2f70421 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "읽지 않은 상태로 표시", "star": "별표 달기", "unstar": "별표 해제", + "pin": "고정", + "unpin": "고정 해제", "move_to": "이동...", "archive": "보관", "delete": "삭제", diff --git a/locales/lv/common.json b/locales/lv/common.json index 856ce746..473645d9 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Atzīmēt kā nelasītu", "star": "Pievienot zvaigznīti", "unstar": "Noņemt zvaigznīti", + "pin": "Piespraust", + "unpin": "Atspraust", "move_to": "Pārvietot uz...", "archive": "Arhivēt", "delete": "Dzēst", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8e9569ce..8ecf0b7f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Markeren als ongelezen", "star": "Ster toevoegen", "unstar": "Ster verwijderen", + "pin": "Vastmaken", + "unpin": "Losmaken", "move_to": "Verplaatsen naar...", "archive": "Archiveren", "delete": "Verwijderen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 746601c6..b0875e9b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Oznacz jako nieprzeczytane", "star": "Oznacz gwiazdką", "unstar": "Usuń gwiazdkę", + "pin": "Przypnij", + "unpin": "Odepnij", "move_to": "Przenieś do...", "archive": "Archiwizuj", "delete": "Usuń", diff --git a/locales/pt/common.json b/locales/pt/common.json index 26efb746..fc526e2f 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Marcar como Não Lido", "star": "Adicionar Estrela", "unstar": "Remover Estrela", + "pin": "Fixar", + "unpin": "Desafixar", "move_to": "Mover para...", "archive": "Arquivar", "delete": "Excluir", diff --git a/locales/ro/common.json b/locales/ro/common.json index 2a3af4cf..12bead77 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Marcați ca necitit", "star": "Stea", "unstar": "Anulează marcarea cu stea", + "pin": "Fixează", + "unpin": "Anulează fixarea", "move_to": "Mergi la...", "archive": "Arhivează", "delete": "Șterge", diff --git a/locales/ru/common.json b/locales/ru/common.json index 6f9f6695..eb851e8d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Отметить как непрочитанное", "star": "Добавить звёздочку", "unstar": "Убрать звёздочку", + "pin": "Закрепить", + "unpin": "Открепить", "move_to": "Переместить в...", "archive": "В архив", "delete": "Удалить", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5fd57572..10e9ac62 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1943,6 +1943,8 @@ "mark_unread": "Označiť ako neprečítané", "star": "Hviezdička", "unstar": "Odstrániť hviezdičku", + "pin": "Pripnúť", + "unpin": "Odopnúť", "move_to": "Presunúť do...", "archive": "Archivovať", "delete": "Odstrániť", diff --git a/locales/tr/common.json b/locales/tr/common.json index e1ecea1d..a0f3fb70 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Okunmadı Olarak İşaretle", "star": "Yıldız Ekle", "unstar": "Yıldızı Kaldır", + "pin": "Sabitle", + "unpin": "Sabitlemeyi kaldır", "move_to": "Şuraya taşı...", "archive": "Arşivle", "delete": "Sil", diff --git a/locales/uk/common.json b/locales/uk/common.json index 0c2403bf..5ccb5ec3 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "Позначити як непрочитане", "star": "зірка", "unstar": "Зняти зірочку", + "pin": "Закріпити", + "unpin": "Відкріпити", "move_to": "Перейти до...", "archive": "Архів", "delete": "Видалити", diff --git a/locales/zh/common.json b/locales/zh/common.json index 2d69e8f0..2532ca76 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1954,6 +1954,8 @@ "mark_unread": "标记为未读", "star": "加星标", "unstar": "取消星标", + "pin": "固定", + "unpin": "取消固定", "move_to": "移动到…", "archive": "归档", "delete": "删除", diff --git a/stores/email-store.ts b/stores/email-store.ts index e5c4da4e..0967f670 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -945,7 +945,7 @@ export const useEmailStore = create((set, get) => ({ // When filtering by tag, omit the mailbox constraint so emails across // all folders that carry the tag are returned. - const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); + const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter, true); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, @@ -1108,7 +1108,7 @@ export const useEmailStore = create((set, get) => ({ const jmapMailboxId = mailbox?.originalId || selectedMailbox; // When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails). - result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); + result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined, true); } if (selectedMailbox === ALL_MAIL_MAILBOX_ID) { @@ -2643,7 +2643,7 @@ export const useEmailStore = create((set, get) => ({ const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0); } else { - result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); + result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, undefined, true); } const currentEmails = get().emails; @@ -2653,7 +2653,9 @@ export const useEmailStore = create((set, get) => ({ // Without these guards the toast/sound also fires when sending, // saving drafts, or moving/deleting the top message in any mailbox, // because all of those change the first-email id of the current view. - const newFirst = result.emails[0]; + // Pinned mails sit above the date order, so the newest mail is the + // first NON-pinned entry (a just-arrived mail cannot be pinned yet). + const newFirst = result.emails.find(e => !e.keywords?.['$pinned']) ?? result.emails[0]; if ( newFirst && mailbox?.role === 'inbox' &&