feat: improve visualization of tags

Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.

This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.

A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:42 +02:00
parent 013ef7d557
commit 108406a885
46 changed files with 1102 additions and 832 deletions
+49 -17
View File
@@ -4,8 +4,9 @@ import {
sortThreadGroups,
getThreadParticipants,
mergeThreadEmails,
getEmailColorTag,
getThreadColorTag,
getEmailTagId,
getThreadTagId,
getThreadTagIds,
} from '../thread-utils';
import type { Email, ThreadGroup } from '../jmap/types';
@@ -245,47 +246,47 @@ describe('mergeThreadEmails', () => {
});
});
describe('getEmailColorTag', () => {
describe('getEmailTagId', () => {
it('returns label from $label: keyword', () => {
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
});
it('returns label from legacy $color: keyword', () => {
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
});
it('returns null when no color keyword', () => {
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
});
it('returns null for undefined keywords', () => {
expect(getEmailColorTag(undefined)).toBeNull();
expect(getEmailTagId(undefined)).toBeNull();
});
it('ignores keywords set to false', () => {
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
});
it('prefers $label: over $color: when both exist', () => {
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
});
it('handles custom keyword ids', () => {
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
});
it('returns null for empty keywords object', () => {
expect(getEmailColorTag({})).toBeNull();
expect(getEmailTagId({})).toBeNull();
});
});
describe('getThreadColorTag', () => {
describe('getThreadTagId', () => {
it('returns first color found across thread emails', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
];
expect(getThreadColorTag(emails)).toBe('blue');
expect(getThreadTagId(emails)).toBe('blue');
});
it('returns null when no emails have color tags', () => {
@@ -293,7 +294,7 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
];
expect(getThreadColorTag(emails)).toBeNull();
expect(getThreadTagId(emails)).toBeNull();
});
it('returns first tag from earliest tagged email', () => {
@@ -301,7 +302,7 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
];
expect(getThreadColorTag(emails)).toBe('red');
expect(getThreadTagId(emails)).toBe('red');
});
it('returns legacy tag from thread emails', () => {
@@ -309,10 +310,41 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
];
expect(getThreadColorTag(emails)).toBe('green');
expect(getThreadTagId(emails)).toBe('green');
});
it('returns null for empty email array', () => {
expect(getThreadColorTag([])).toBeNull();
expect(getThreadTagId([])).toBeNull();
});
});
describe('getThreadTagIds', () => {
it('gathers the tags of every message in the thread', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }),
];
expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']);
});
it('reports a tag shared by several messages once', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
];
expect(getThreadTagIds(emails)).toEqual(['red']);
});
it('reads the legacy prefix alongside the current one', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { '$color:green': true } }),
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
];
expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']);
});
it('is empty for an untagged or empty thread', () => {
expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]);
expect(getThreadTagIds([])).toEqual([]);
});
});