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:
dealerweb
2026-07-06 13:36:25 +02:00
committed by Linus Rath
parent d7a64fd9d6
commit d384c3b553
33 changed files with 194 additions and 39 deletions
+37 -25
View File
@@ -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,