@remit/web-client 0.0.76 → 0.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/LabelApplyTrigger.tsx +61 -0
  3. package/src/components/mail/MailboxPane.tsx +1 -34
  4. package/src/components/mail/MessageList.tsx +1 -0
  5. package/src/components/mail/MessageListItem.test.ts +43 -0
  6. package/src/components/mail/MessageListItem.tsx +1 -0
  7. package/src/components/mail/SelectionToolbar.render.test.ts +51 -1
  8. package/src/components/mail/SelectionToolbar.stories.tsx +5 -0
  9. package/src/components/mail/SelectionToolbar.tsx +21 -0
  10. package/src/components/mail/organize/OrganizeRuleEditor.tsx +20 -0
  11. package/src/components/settings/FilterEditor.render.test.ts +1 -0
  12. package/src/components/settings/FilterEditor.tsx +18 -0
  13. package/src/components/settings/FilterEditorSurface.tsx +9 -1
  14. package/src/components/settings/FiltersList.render.test.ts +16 -0
  15. package/src/components/settings/FiltersList.tsx +32 -8
  16. package/src/components/settings/LabelsList.tsx +112 -0
  17. package/src/components/settings/settings-filter.stories.tsx +10 -1
  18. package/src/hooks/useApplyLabel.ts +68 -0
  19. package/src/hooks/useLabels.ts +124 -0
  20. package/src/hooks/useRuleEditorState.ts +8 -0
  21. package/src/lib/inbox-filters.test.ts +106 -0
  22. package/src/lib/inbox-filters.ts +44 -0
  23. package/src/lib/organize/filter-edit-model.test.ts +38 -0
  24. package/src/lib/organize/filter-edit-model.ts +15 -10
  25. package/src/lib/organize/label-delete-copy.test.ts +21 -0
  26. package/src/lib/organize/label-delete-copy.ts +22 -0
  27. package/src/lib/organize/organize-model.test.ts +35 -7
  28. package/src/lib/organize/organize-model.ts +17 -12
  29. package/src/lib/organize/rule-model.test.ts +5 -0
  30. package/src/lib/organize/rule-model.ts +1 -0
  31. package/src/routeTree.gen.ts +21 -0
  32. package/src/routes/settings/filters.tsx +19 -1
  33. package/src/routes/settings/labels.tsx +242 -0
  34. package/src/routes/settings.tsx +7 -1
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The delete-confirmation copy for a label (issue #26). Deleting a label
3
+ * cascades in both directions server-side — every filter whose action applies
4
+ * it is deleted outright, never left dangling — so the confirmation names the
5
+ * filter count up front rather than surprising the user after the fact. The
6
+ * API itself never blocks the delete; the confirmation is the only gate.
7
+ */
8
+ export const deleteLabelConfirmCopy = (
9
+ labelName: string,
10
+ filterCount: number,
11
+ ): { title: string; description?: string } => {
12
+ if (filterCount === 0) {
13
+ return { title: `Delete the "${labelName}" label?` };
14
+ }
15
+ const filterNoun = filterCount === 1 ? "filter" : "filters";
16
+ return {
17
+ title: `Delete the "${labelName}" label?`,
18
+ description: `This also deletes ${filterCount} ${filterNoun} that ${
19
+ filterCount === 1 ? "applies" : "apply"
20
+ } it — they can't be recovered.`,
21
+ };
22
+ };
@@ -15,13 +15,15 @@ const baseDraft = (overrides: Partial<OrganizeDraft> = {}): OrganizeDraft => ({
15
15
  });
16
16
 
17
17
  describe("hasCommittableAction", () => {
18
- it("is false when no move target is chosen — labeling has no backend yet, so a keep-in-place draft has nothing to commit", () => {
18
+ it("is false when neither a move nor a label is chosen — a keep-in-place draft has nothing to commit", () => {
19
19
  assert.equal(hasCommittableAction(baseDraft()), false);
20
20
  });
21
21
 
22
- it("is false for the None sentinel", () => {
22
+ it("is false for the None sentinel on both actions", () => {
23
23
  assert.equal(
24
- hasCommittableAction(baseDraft({ moveMailboxId: NO_ACTION })),
24
+ hasCommittableAction(
25
+ baseDraft({ moveMailboxId: NO_ACTION, labelId: NO_ACTION }),
26
+ ),
25
27
  false,
26
28
  );
27
29
  });
@@ -32,10 +34,23 @@ describe("hasCommittableAction", () => {
32
34
  true,
33
35
  );
34
36
  });
37
+
38
+ it("is true once a real label is chosen, with no move target (issue #26)", () => {
39
+ assert.equal(hasCommittableAction(baseDraft({ labelId: "lbl-1" })), true);
40
+ });
41
+
42
+ it("is true when both a move and a label are chosen", () => {
43
+ assert.equal(
44
+ hasCommittableAction(
45
+ baseDraft({ moveMailboxId: "mbx-1", labelId: "lbl-1" }),
46
+ ),
47
+ true,
48
+ );
49
+ });
35
50
  });
36
51
 
37
52
  describe("buildOrganizeInput", () => {
38
- it("carries the anchor and defaults the action to None when no move is set", () => {
53
+ it("carries the anchor and defaults both actions to None when neither is set", () => {
39
54
  const input = buildOrganizeInput(baseDraft({ anchorMessageId: "msg-1" }));
40
55
  assert.equal(input.anchorMessageId, "msg-1");
41
56
  assert.equal(input.actionMailboxId, NO_ACTION);
@@ -53,12 +68,16 @@ describe("buildOrganizeInput", () => {
53
68
  assert.equal("anchorMessageId" in input, false);
54
69
  });
55
70
 
56
- it("labels the label action None even when a move target is set — label writes have no endpoint", () => {
71
+ it("carries the move and label actions independently (issue #26)", () => {
57
72
  const input = buildOrganizeInput(
58
- baseDraft({ anchorMessageId: "msg-1", moveMailboxId: "mbx-9" }),
73
+ baseDraft({
74
+ anchorMessageId: "msg-1",
75
+ moveMailboxId: "mbx-9",
76
+ labelId: "lbl-9",
77
+ }),
59
78
  );
60
79
  assert.equal(input.actionMailboxId, "mbx-9");
61
- assert.equal(input.actionLabelId, NO_ACTION);
80
+ assert.equal(input.actionLabelId, "lbl-9");
62
81
  });
63
82
  });
64
83
 
@@ -101,4 +120,13 @@ describe("buildCreateFilterInput", () => {
101
120
  );
102
121
  assert.equal("ttl" in input, false);
103
122
  });
123
+
124
+ it("carries a label action (issue #26)", () => {
125
+ const input = buildCreateFilterInput(
126
+ baseDraft({ labelId: "lbl-1" }),
127
+ "standing",
128
+ "Receipts",
129
+ );
130
+ assert.equal(input.actionLabelId, "lbl-1");
131
+ });
104
132
  });
@@ -27,9 +27,10 @@ export const NO_ACTION = "None";
27
27
 
28
28
  /**
29
29
  * The user's in-progress organize decision, independent of which scope they
30
- * land on. The anchor and predicate drive the match set; `moveMailboxId` is the
31
- * one committable action today (labeling has no backend yet — see
32
- * `labelPlaceholder`).
30
+ * land on. The anchor and predicate drive the match set; `moveMailboxId` and
31
+ * `labelId` are the two committable actions (issue #26) independent of each
32
+ * other, since a move is exclusive and a label is additive (RFC 034 Decision
33
+ * 3.1).
33
34
  */
34
35
  export interface OrganizeDraft {
35
36
  /** Semantic anchor — "mail like this one". The first selected message. */
@@ -43,6 +44,11 @@ export interface OrganizeDraft {
43
44
  * Decision 3.1). Absent means "keep where they are": no move action.
44
45
  */
45
46
  moveMailboxId?: string;
47
+ /**
48
+ * Label applied to the match set — the additive action (RFC 034 Decision
49
+ * 3.1, issue #26). Absent applies no label.
50
+ */
51
+ labelId?: string;
46
52
  /**
47
53
  * ISO 8601 date-time with zone offset. Present only for the `temporary`
48
54
  * scope; a plain picked date (RFC 034 non-goal: no event-based expiry).
@@ -51,14 +57,13 @@ export interface OrganizeDraft {
51
57
  }
52
58
 
53
59
  /**
54
- * Whether the draft carries a committable action. Labeling is not wired yet
55
- * (no Label API RFC 030's `Label`/`MessageLabel` entities exist in TypeSpec
56
- * but have no CRUD endpoint), so a move target is the only real action. A draft
57
- * with no move target has nothing to commit; the caller disables the CTA and
58
- * says why (ux.md).
60
+ * Whether the draft carries a committable action a move destination and/or
61
+ * a label (issue #26), either satisfies this. A draft with neither has
62
+ * nothing to commit; the caller disables the CTA and says why (ux.md).
59
63
  */
60
64
  export const hasCommittableAction = (draft: OrganizeDraft): boolean =>
61
- draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION;
65
+ (draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION) ||
66
+ (draft.labelId !== undefined && draft.labelId !== NO_ACTION);
62
67
 
63
68
  /**
64
69
  * Whether the draft's predicate can be back-applied over the existing corpus. A
@@ -75,7 +80,7 @@ export const canBackApplyDraft = (draft: OrganizeDraft): boolean =>
75
80
  * Build the read-only preview / back-apply matcher input. The action fields do
76
81
  * not affect which messages match — the preview returns exactly the set a job
77
82
  * with the same predicate would apply to — so a widen preview can pass this
78
- * before the user has chosen a folder.
83
+ * before the user has chosen a folder or a label.
79
84
  */
80
85
  export const buildOrganizeInput = (
81
86
  draft: OrganizeDraft,
@@ -83,7 +88,7 @@ export const buildOrganizeInput = (
83
88
  ...(draft.anchorMessageId ? { anchorMessageId: draft.anchorMessageId } : {}),
84
89
  matchOperator: draft.matchOperator,
85
90
  literalClauses: draft.literalClauses,
86
- actionLabelId: NO_ACTION,
91
+ actionLabelId: draft.labelId ?? NO_ACTION,
87
92
  actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
88
93
  });
89
94
 
@@ -107,7 +112,7 @@ export const buildCreateFilterInput = (
107
112
  : {}),
108
113
  matchOperator: draft.matchOperator,
109
114
  literalClauses: draft.literalClauses,
110
- actionLabelId: NO_ACTION,
115
+ actionLabelId: draft.labelId ?? NO_ACTION,
111
116
  actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
112
117
  ...(draft.anchorMessageId
113
118
  ? { anchorMessageId: draft.anchorMessageId }
@@ -247,6 +247,11 @@ describe("the previewed set equals the applied set", () => {
247
247
  "mbx-archive",
248
248
  );
249
249
  });
250
+
251
+ it("carries the label target into the draft (issue #26)", () => {
252
+ const labeled = { ...semanticRule, labelId: "lbl-receipts" };
253
+ assert.equal(ruleToDraft(labeled, "msg-1").labelId, "lbl-receipts");
254
+ });
250
255
  });
251
256
 
252
257
  describe("derivePreview", () => {
@@ -164,6 +164,7 @@ export const ruleToDraft = (
164
164
  matchOperator: predicate.matchOperator,
165
165
  literalClauses: predicate.literalClauses,
166
166
  moveMailboxId: rule.moveMailboxId,
167
+ labelId: rule.labelId,
167
168
  expiresAt:
168
169
  rule.scope === "until"
169
170
  ? pickedDateToExpiresAt(rule.until ?? "")
@@ -23,6 +23,7 @@ import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced
23
23
  import { Route as SettingsAppearanceRouteImport } from './routes/settings/appearance'
24
24
  import { Route as SettingsFiltersRouteImport } from './routes/settings/filters'
25
25
  import { Route as SettingsFoldersRouteImport } from './routes/settings/folders'
26
+ import { Route as SettingsLabelsRouteImport } from './routes/settings/labels'
26
27
  import { Route as SettingsSendersRouteImport } from './routes/settings/senders'
27
28
  import { Route as SettingsSuggestedVipsRouteImport } from './routes/settings/suggested-vips'
28
29
 
@@ -96,6 +97,11 @@ const SettingsFoldersRoute = SettingsFoldersRouteImport.update({
96
97
  path: '/folders',
97
98
  getParentRoute: () => SettingsRoute,
98
99
  } as any)
100
+ const SettingsLabelsRoute = SettingsLabelsRouteImport.update({
101
+ id: '/labels',
102
+ path: '/labels',
103
+ getParentRoute: () => SettingsRoute,
104
+ } as any)
99
105
  const SettingsSendersRoute = SettingsSendersRouteImport.update({
100
106
  id: '/senders',
101
107
  path: '/senders',
@@ -120,6 +126,7 @@ export interface FileRoutesByFullPath {
120
126
  '/settings/appearance': typeof SettingsAppearanceRoute
121
127
  '/settings/filters': typeof SettingsFiltersRoute
122
128
  '/settings/folders': typeof SettingsFoldersRoute
129
+ '/settings/labels': typeof SettingsLabelsRoute
123
130
  '/settings/senders': typeof SettingsSendersRoute
124
131
  '/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
125
132
  '/mail/': typeof MailIndexRoute
@@ -136,6 +143,7 @@ export interface FileRoutesByTo {
136
143
  '/settings/appearance': typeof SettingsAppearanceRoute
137
144
  '/settings/filters': typeof SettingsFiltersRoute
138
145
  '/settings/folders': typeof SettingsFoldersRoute
146
+ '/settings/labels': typeof SettingsLabelsRoute
139
147
  '/settings/senders': typeof SettingsSendersRoute
140
148
  '/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
141
149
  '/mail': typeof MailIndexRoute
@@ -155,6 +163,7 @@ export interface FileRoutesById {
155
163
  '/settings/appearance': typeof SettingsAppearanceRoute
156
164
  '/settings/filters': typeof SettingsFiltersRoute
157
165
  '/settings/folders': typeof SettingsFoldersRoute
166
+ '/settings/labels': typeof SettingsLabelsRoute
158
167
  '/settings/senders': typeof SettingsSendersRoute
159
168
  '/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
160
169
  '/mail/': typeof MailIndexRoute
@@ -175,6 +184,7 @@ export interface FileRouteTypes {
175
184
  | '/settings/appearance'
176
185
  | '/settings/filters'
177
186
  | '/settings/folders'
187
+ | '/settings/labels'
178
188
  | '/settings/senders'
179
189
  | '/settings/suggested-vips'
180
190
  | '/mail/'
@@ -191,6 +201,7 @@ export interface FileRouteTypes {
191
201
  | '/settings/appearance'
192
202
  | '/settings/filters'
193
203
  | '/settings/folders'
204
+ | '/settings/labels'
194
205
  | '/settings/senders'
195
206
  | '/settings/suggested-vips'
196
207
  | '/mail'
@@ -209,6 +220,7 @@ export interface FileRouteTypes {
209
220
  | '/settings/appearance'
210
221
  | '/settings/filters'
211
222
  | '/settings/folders'
223
+ | '/settings/labels'
212
224
  | '/settings/senders'
213
225
  | '/settings/suggested-vips'
214
226
  | '/mail/'
@@ -322,6 +334,13 @@ declare module '@tanstack/react-router' {
322
334
  preLoaderRoute: typeof SettingsFoldersRouteImport
323
335
  parentRoute: typeof SettingsRoute
324
336
  }
337
+ '/settings/labels': {
338
+ id: '/settings/labels'
339
+ path: '/labels'
340
+ fullPath: '/settings/labels'
341
+ preLoaderRoute: typeof SettingsLabelsRouteImport
342
+ parentRoute: typeof SettingsRoute
343
+ }
325
344
  '/settings/senders': {
326
345
  id: '/settings/senders'
327
346
  path: '/senders'
@@ -361,6 +380,7 @@ interface SettingsRouteChildren {
361
380
  SettingsAppearanceRoute: typeof SettingsAppearanceRoute
362
381
  SettingsFiltersRoute: typeof SettingsFiltersRoute
363
382
  SettingsFoldersRoute: typeof SettingsFoldersRoute
383
+ SettingsLabelsRoute: typeof SettingsLabelsRoute
364
384
  SettingsSendersRoute: typeof SettingsSendersRoute
365
385
  SettingsSuggestedVipsRoute: typeof SettingsSuggestedVipsRoute
366
386
  SettingsIndexRoute: typeof SettingsIndexRoute
@@ -372,6 +392,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
372
392
  SettingsAppearanceRoute: SettingsAppearanceRoute,
373
393
  SettingsFiltersRoute: SettingsFiltersRoute,
374
394
  SettingsFoldersRoute: SettingsFoldersRoute,
395
+ SettingsLabelsRoute: SettingsLabelsRoute,
375
396
  SettingsSendersRoute: SettingsSendersRoute,
376
397
  SettingsSuggestedVipsRoute: SettingsSuggestedVipsRoute,
377
398
  SettingsIndexRoute: SettingsIndexRoute,
@@ -3,7 +3,7 @@ import {
3
3
  mailboxOperationsListMailboxesOptions,
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
5
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
6
- import { SettingsShell } from "@remit/ui";
6
+ import { type LabelOption, SettingsShell } from "@remit/ui";
7
7
  import { useQuery } from "@tanstack/react-query";
8
8
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
9
9
  import { useCallback, useMemo, useState } from "react";
@@ -11,6 +11,7 @@ import { FilterEditorSurface } from "@/components/settings/FilterEditorSurface";
11
11
  import { FiltersList } from "@/components/settings/FiltersList";
12
12
  import { ErrorState } from "@/components/ui/ErrorState";
13
13
  import { useDeleteFilter, useFilterList } from "@/hooks/useFilters";
14
+ import { useLabelList } from "@/hooks/useLabels";
14
15
  import { getMailboxDisplayName } from "@/lib/folder-roles";
15
16
  import { buildMoveTargets } from "@/lib/move-targets";
16
17
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
@@ -64,6 +65,21 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
64
65
  [mailboxesData?.items],
65
66
  );
66
67
 
68
+ const { labels: labelItems } = useLabelList(accountId);
69
+ const labels: LabelOption[] = useMemo(
70
+ () =>
71
+ labelItems.map((label) => ({
72
+ id: label.labelId,
73
+ name: label.name,
74
+ color: label.color,
75
+ })),
76
+ [labelItems],
77
+ );
78
+ const labelById = useMemo(
79
+ () => new Map(labels.map((label) => [label.id, label])),
80
+ [labels],
81
+ );
82
+
67
83
  const editingFilter = filters.find(
68
84
  (filter) => filter.filterId === editingFilterId,
69
85
  );
@@ -76,6 +92,7 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
76
92
  accountId={accountId}
77
93
  filter={editingFilter}
78
94
  folders={folders}
95
+ labels={labels}
79
96
  onClose={() => setEditingFilterId(undefined)}
80
97
  />
81
98
  )}
@@ -99,6 +116,7 @@ function AccountFilters({ account }: { account: RemitImapAccountResponse }) {
99
116
  <FiltersList
100
117
  filters={filters}
101
118
  mailboxName={mailboxName}
119
+ labelById={labelById}
102
120
  onEdit={setEditingFilterId}
103
121
  onDelete={deleteFilter}
104
122
  deletingFilterId={deletingFilterId}
@@ -0,0 +1,242 @@
1
+ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type {
3
+ RemitImapAccountResponse,
4
+ RemitImapLabelColor,
5
+ RemitImapLabelResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import {
8
+ Banner,
9
+ Button,
10
+ Input,
11
+ labelColorOptions,
12
+ Select,
13
+ SettingsShell,
14
+ } from "@remit/ui";
15
+ import { useQuery } from "@tanstack/react-query";
16
+ import { createFileRoute, useNavigate } from "@tanstack/react-router";
17
+ import { useState } from "react";
18
+ import { LabelsList } from "@/components/settings/LabelsList";
19
+ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
20
+ import { ErrorState } from "@/components/ui/ErrorState";
21
+ import {
22
+ useCreateLabel,
23
+ useDeleteLabel,
24
+ useLabelList,
25
+ useUpdateLabel,
26
+ } from "@/hooks/useLabels";
27
+ import { deleteLabelConfirmCopy } from "@/lib/organize/label-delete-copy";
28
+ import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
29
+
30
+ export const Route = createFileRoute("/settings/labels")({
31
+ component: LabelsSettings,
32
+ });
33
+
34
+ const labelsHelp = (
35
+ <div className="space-y-3">
36
+ <p>
37
+ Labels mark mail without moving it — a message can carry any number of
38
+ them alongside whichever folder it lives in.
39
+ </p>
40
+ <p>
41
+ Use a label in a filter's action to have new (and existing) mail label
42
+ itself automatically, or apply one to a selection directly from the
43
+ mailbox.
44
+ </p>
45
+ <p>
46
+ Deleting a label removes it from every message, and deletes every filter
47
+ that applies it — the confirmation names how many.
48
+ </p>
49
+ </div>
50
+ );
51
+
52
+ /** Create a label for one account. A name, a color, and a create button. */
53
+ function NewLabel({ accountId }: { accountId: string }) {
54
+ const { createLabel, isPending, isError } = useCreateLabel(accountId);
55
+ const [name, setName] = useState("");
56
+ const [color, setColor] = useState<RemitImapLabelColor>("Default");
57
+
58
+ const handleCreate = () => {
59
+ const trimmed = name.trim();
60
+ if (trimmed === "") return;
61
+ createLabel(trimmed, color).then(() => {
62
+ setName("");
63
+ setColor("Default");
64
+ });
65
+ };
66
+
67
+ return (
68
+ <div className="space-y-2 rounded-sm border border-line bg-surface p-3">
69
+ <p className="text-sm font-medium text-fg">New label</p>
70
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
71
+ <div className="flex-1 space-y-1">
72
+ <span className="text-xs text-fg-muted">Name</span>
73
+ <Input
74
+ value={name}
75
+ onChange={(event) => setName(event.target.value)}
76
+ placeholder="e.g. Receipts"
77
+ aria-label="Label name"
78
+ onKeyDown={(event) => {
79
+ if (event.key === "Enter") {
80
+ event.preventDefault();
81
+ handleCreate();
82
+ }
83
+ }}
84
+ />
85
+ </div>
86
+ <div className="space-y-1">
87
+ <span className="text-xs text-fg-muted">Color</span>
88
+ <Select
89
+ value={color}
90
+ onChange={(event) =>
91
+ setColor(event.target.value as RemitImapLabelColor)
92
+ }
93
+ aria-label="Label color"
94
+ className="w-28"
95
+ >
96
+ {labelColorOptions.map((option) => (
97
+ <option key={option} value={option}>
98
+ {option}
99
+ </option>
100
+ ))}
101
+ </Select>
102
+ </div>
103
+ <Button
104
+ variant="primary"
105
+ onClick={handleCreate}
106
+ disabled={isPending || name.trim() === ""}
107
+ >
108
+ {isPending ? "Creating…" : "Create label"}
109
+ </Button>
110
+ </div>
111
+ {isError && (
112
+ <Banner tone="danger" variant="soft">
113
+ Couldn't create that label. Please try again.
114
+ </Banner>
115
+ )}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ function AccountLabels({ account }: { account: RemitImapAccountResponse }) {
121
+ const accountId = account.accountId;
122
+ const { labels, isPending, isError, error, refetch } =
123
+ useLabelList(accountId);
124
+ const { updateLabel } = useUpdateLabel(accountId);
125
+ const { deleteLabel, deletingLabelId } = useDeleteLabel(accountId);
126
+ const [pendingDelete, setPendingDelete] = useState<RemitImapLabelResponse>();
127
+
128
+ return (
129
+ <section className="space-y-2">
130
+ <h2 className="text-sm font-semibold text-fg">{account.email}</h2>
131
+ {isPending ? (
132
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on loading skeleton provides useful context for assistive tech
133
+ <div
134
+ className="h-16 animate-pulse rounded-md border border-line bg-surface"
135
+ aria-busy="true"
136
+ aria-label={`Loading labels for ${account.email}`}
137
+ />
138
+ ) : isError ? (
139
+ <ErrorState
140
+ variant="inline"
141
+ title={`Couldn't load labels for ${account.email}`}
142
+ error={error}
143
+ onRetry={() => {
144
+ refetch();
145
+ }}
146
+ />
147
+ ) : (
148
+ <LabelsList
149
+ labels={labels}
150
+ onRename={(labelId, name) => updateLabel(labelId, { name })}
151
+ onRecolor={(labelId, color) =>
152
+ updateLabel(labelId, {
153
+ color: color as RemitImapLabelResponse["color"],
154
+ })
155
+ }
156
+ onDelete={setPendingDelete}
157
+ deletingLabelId={deletingLabelId}
158
+ />
159
+ )}
160
+ <NewLabel accountId={accountId} />
161
+ {pendingDelete &&
162
+ (() => {
163
+ const copy = deleteLabelConfirmCopy(
164
+ pendingDelete.name,
165
+ pendingDelete.filterCount,
166
+ );
167
+ return (
168
+ <ConfirmDialog
169
+ isOpen
170
+ title={copy.title}
171
+ description={copy.description}
172
+ confirmLabel="Delete label"
173
+ destructive
174
+ isBusy={deletingLabelId === pendingDelete.labelId}
175
+ onConfirm={() => {
176
+ deleteLabel(pendingDelete.labelId);
177
+ setPendingDelete(undefined);
178
+ }}
179
+ onCancel={() => setPendingDelete(undefined)}
180
+ />
181
+ );
182
+ })()}
183
+ </section>
184
+ );
185
+ }
186
+
187
+ function LabelsSettings() {
188
+ const navigate = useNavigate();
189
+ const [helpOpen, setHelpOpen] = useState(true);
190
+
191
+ const {
192
+ data: config,
193
+ isPending,
194
+ isError,
195
+ error,
196
+ refetch,
197
+ } = useQuery(configOperationsGetConfigOptions());
198
+
199
+ const handleSelectNav = (id: string) => {
200
+ const path = SETTINGS_ID_TO_PATH[id];
201
+ if (path) void navigate({ to: path });
202
+ };
203
+
204
+ return (
205
+ <SettingsShell
206
+ items={SETTINGS_NAV_ITEMS}
207
+ activeId="labels"
208
+ title="Labels"
209
+ description="Mark mail without moving it, per account."
210
+ help={labelsHelp}
211
+ helpOpen={helpOpen}
212
+ onToggleHelp={() => setHelpOpen((v) => !v)}
213
+ onSelect={handleSelectNav}
214
+ onBackToMail={() => void navigate({ to: "/mail" })}
215
+ >
216
+ {isPending ? (
217
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on loading skeleton provides useful context for assistive tech
218
+ <div
219
+ className="h-24 animate-pulse rounded-sm border border-line bg-surface"
220
+ aria-busy="true"
221
+ aria-label="Loading accounts"
222
+ />
223
+ ) : isError ? (
224
+ <ErrorState
225
+ title="Couldn't load accounts"
226
+ error={error}
227
+ onRetry={() => {
228
+ refetch();
229
+ }}
230
+ />
231
+ ) : config.accounts.length === 0 ? (
232
+ <p className="py-12 text-sm text-fg-muted">No accounts configured.</p>
233
+ ) : (
234
+ <div className="space-y-8">
235
+ {config.accounts.map((account) => (
236
+ <AccountLabels key={account.accountId} account={account} />
237
+ ))}
238
+ </div>
239
+ )}
240
+ </SettingsShell>
241
+ );
242
+ }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { SettingsNavItem } from "@remit/ui";
12
12
  import { createFileRoute, Outlet } from "@tanstack/react-router";
13
- import { Filter, FolderTree, Inbox, Palette, Users } from "lucide-react";
13
+ import { Filter, FolderTree, Inbox, Palette, Tag, Users } from "lucide-react";
14
14
  import { AdvancedNavIcon } from "@/components/settings/AdvancedNavIcon";
15
15
 
16
16
  /* ------------------------------------------------------------------ */
@@ -34,6 +34,11 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
34
34
  label: "Filters",
35
35
  icon: <Filter className="size-4" />,
36
36
  },
37
+ {
38
+ id: "labels",
39
+ label: "Labels",
40
+ icon: <Tag className="size-4" />,
41
+ },
37
42
  {
38
43
  id: "appearance",
39
44
  label: "Appearance",
@@ -47,6 +52,7 @@ export const SETTINGS_ID_TO_PATH: Record<string, string> = {
47
52
  senders: "/settings/senders",
48
53
  folders: "/settings/folders",
49
54
  filters: "/settings/filters",
55
+ labels: "/settings/labels",
50
56
  appearance: "/settings/appearance",
51
57
  advanced: "/settings/advanced",
52
58
  };