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:
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -78,7 +78,9 @@ export interface IJMAPClient {
|
||||
deleteMailbox(mailboxId: string): Promise<void>;
|
||||
|
||||
// ── 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<Email[]>;
|
||||
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
||||
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
||||
|
||||
+13
-2
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user