@remit/web-client 0.0.75 → 0.0.77

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/LabelApplyTrigger.tsx +61 -0
  3. package/src/components/mail/MessageList.tsx +1 -0
  4. package/src/components/mail/MessageListItem.test.ts +43 -0
  5. package/src/components/mail/MessageListItem.tsx +1 -0
  6. package/src/components/mail/MoveToTrigger.tsx +2 -2
  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/DeleteFolderDialog.tsx +2 -1
  12. package/src/components/settings/FilterEditor.render.test.ts +1 -0
  13. package/src/components/settings/FilterEditor.tsx +18 -0
  14. package/src/components/settings/FilterEditorSurface.tsx +9 -1
  15. package/src/components/settings/FiltersList.render.test.ts +16 -0
  16. package/src/components/settings/FiltersList.tsx +32 -8
  17. package/src/components/settings/LabelsList.tsx +112 -0
  18. package/src/components/settings/settings-filter.stories.tsx +10 -1
  19. package/src/hooks/useApplyLabel.ts +68 -0
  20. package/src/hooks/useCreateMailbox.render.test.ts +109 -9
  21. package/src/hooks/useCreateMailbox.ts +64 -23
  22. package/src/hooks/useLabels.ts +124 -0
  23. package/src/hooks/useRuleEditorState.ts +8 -0
  24. package/src/lib/mailbox-sync-wait.test.ts +186 -0
  25. package/src/lib/mailbox-sync-wait.ts +98 -0
  26. package/src/lib/organize/filter-edit-model.test.ts +38 -0
  27. package/src/lib/organize/filter-edit-model.ts +15 -10
  28. package/src/lib/organize/label-delete-copy.test.ts +21 -0
  29. package/src/lib/organize/label-delete-copy.ts +22 -0
  30. package/src/lib/organize/organize-model.test.ts +35 -7
  31. package/src/lib/organize/organize-model.ts +17 -12
  32. package/src/lib/organize/rule-model.test.ts +5 -0
  33. package/src/lib/organize/rule-model.ts +1 -0
  34. package/src/routeTree.gen.ts +21 -0
  35. package/src/routes/settings/filters.tsx +19 -1
  36. package/src/routes/settings/labels.tsx +242 -0
  37. package/src/routes/settings.tsx +7 -1
@@ -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
  };