@remit/web-client 0.0.112 → 0.0.114
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.
- package/package.json +1 -1
- package/src/components/layout/MailTopBar.tsx +23 -68
- package/src/components/mail/MailNav.tsx +1 -1
- package/src/components/mail/SwipeableMessageRow.render.test.ts +110 -0
- package/src/components/mail/SwipeableMessageRow.tsx +13 -27
- package/src/components/ui/BugReportButton.tsx +14 -39
- package/src/lib/bug-report.ts +5 -0
- package/src/lib/keymap.test.ts +20 -1
- package/src/lib/keymap.ts +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.114",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -1,41 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MailTopBar — the
|
|
2
|
+
* MailTopBar — the `/mail` shell's top bar, wired to the app.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* shortcut has one target.
|
|
9
|
-
*
|
|
10
|
-
* The actions here are the ones that belong to the app rather than to whatever
|
|
11
|
-
* is currently listed or open — the nav toggle, compose, bug report, settings,
|
|
12
|
-
* account. Reply, delete, move and the rest stay on the reading pane's own
|
|
13
|
-
* toolbar, under this bar.
|
|
14
|
-
*
|
|
15
|
-
* The field carries one chip: the scope of the view the user navigated into
|
|
16
|
-
* (`in:spam` in Spam, nothing on the brief). Removing it goes to the brief and
|
|
17
|
-
* searches everything. Typed `in:`/`from:` terms are not chipped here — they
|
|
18
|
-
* are already visible as the text the user typed, and chipping them would show
|
|
19
|
-
* the same term twice in one field; they render as chips over the result
|
|
20
|
-
* sections instead, where the text is not repeated.
|
|
4
|
+
* The bar itself is `ShellTopBar` in the kit. This supplies what the kit cannot
|
|
5
|
+
* know: the search scope the route carries, the app's routes, its keymap and
|
|
6
|
+
* its signed-in session. Which actions the bar carries, in what order and with
|
|
7
|
+
* what wording is not decided here.
|
|
21
8
|
*/
|
|
22
9
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
23
|
-
import {
|
|
10
|
+
import { ShellTopBar } from "@remit/ui";
|
|
24
11
|
import { useNavigate } from "@tanstack/react-router";
|
|
25
|
-
import { Settings, SquarePen } from "lucide-react";
|
|
26
12
|
import { AccountMenu } from "@/auth/AccountMenu";
|
|
27
|
-
import { BugReportButton } from "@/components/ui/BugReportButton";
|
|
28
13
|
import { useGlobalCompose } from "@/hooks/useComposeTarget";
|
|
29
14
|
import { useSearchScope } from "@/hooks/useSearchScope";
|
|
30
|
-
import {
|
|
15
|
+
import { openBugReport } from "@/lib/bug-report";
|
|
16
|
+
import { shortcutHintForAction } from "@/lib/keymap";
|
|
31
17
|
import { useMailContext } from "@/lib/mail-context";
|
|
32
|
-
import type { SearchScopeState } from "@/lib/search-scope";
|
|
33
|
-
|
|
34
|
-
const SEARCH_PLACEHOLDER: Record<SearchScopeState["kind"], string> = {
|
|
35
|
-
global: "Search all mail",
|
|
36
|
-
pending: "Search mail",
|
|
37
|
-
scoped: "Search this folder",
|
|
38
|
-
};
|
|
39
18
|
|
|
40
19
|
interface MailTopBarProps {
|
|
41
20
|
accounts: RemitImapAccountResponse[];
|
|
@@ -51,47 +30,23 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
|
51
30
|
scope.kind === "scoped"
|
|
52
31
|
? [{ id: scope.chip.id, label: scope.chip.label, tone: "scope" as const }]
|
|
53
32
|
: undefined;
|
|
54
|
-
// Only the brief may claim to search all mail. A mailbox route whose name has
|
|
55
|
-
// not loaded yet has no chip to show but is already narrowed, so it gets the
|
|
56
|
-
// neutral wording rather than a placeholder that asserts the wrong scope.
|
|
57
|
-
const placeholder = SEARCH_PLACEHOLDER[scope.kind];
|
|
58
33
|
|
|
59
34
|
return (
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<Button
|
|
76
|
-
variant="ghost"
|
|
77
|
-
size="sm"
|
|
78
|
-
icon={<SquarePen className="size-4" />}
|
|
79
|
-
title={`Compose ${tooltipForAction("compose")}`}
|
|
80
|
-
aria-label="Compose"
|
|
81
|
-
onClick={compose}
|
|
82
|
-
/>
|
|
83
|
-
<BugReportButton />
|
|
84
|
-
<Button
|
|
85
|
-
variant="ghost"
|
|
86
|
-
size="sm"
|
|
87
|
-
icon={<Settings className="size-4" />}
|
|
88
|
-
title="Settings"
|
|
89
|
-
aria-label="Settings"
|
|
90
|
-
onClick={() => navigate({ to: "/settings/accounts" })}
|
|
91
|
-
/>
|
|
92
|
-
<AccountMenu />
|
|
93
|
-
</>
|
|
94
|
-
}
|
|
35
|
+
<ShellTopBar
|
|
36
|
+
search={{
|
|
37
|
+
value: searchInput,
|
|
38
|
+
scope: scope.kind,
|
|
39
|
+
chips,
|
|
40
|
+
onChange: onSearchChange,
|
|
41
|
+
onClear: onSearchClear,
|
|
42
|
+
onClearQuery: onSearchClearQuery,
|
|
43
|
+
onRemoveChip: clearScope,
|
|
44
|
+
}}
|
|
45
|
+
onCompose={compose}
|
|
46
|
+
onReportBug={openBugReport}
|
|
47
|
+
onOpenSettings={() => navigate({ to: "/settings/accounts" })}
|
|
48
|
+
composeShortcut={shortcutHintForAction("compose")}
|
|
49
|
+
account={<AccountMenu />}
|
|
95
50
|
/>
|
|
96
51
|
);
|
|
97
52
|
}
|
|
@@ -47,7 +47,7 @@ export function MailNav({ accounts, onMailboxSelect }: MailNavProps) {
|
|
|
47
47
|
<Settings className="size-4 shrink-0" />
|
|
48
48
|
<span className="flex-1 truncate text-left">Settings</span>
|
|
49
49
|
</Link>
|
|
50
|
-
<BugReportButton
|
|
50
|
+
<BugReportButton />
|
|
51
51
|
<AccountSession>
|
|
52
52
|
{({ signOut }) => (
|
|
53
53
|
<button
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The touch row is not an anchor (#116).
|
|
3
|
+
*
|
|
4
|
+
* An `<a href>` is what the OS long-press callout and the link context menu
|
|
5
|
+
* fire on, and they raced the row's own long-press-to-select gesture. Nothing
|
|
6
|
+
* an anchor buys — middle-click, open in new tab, copy link address — exists
|
|
7
|
+
* without a mouse, and the swipe row is the touch row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
13
|
+
import {
|
|
14
|
+
type AnyRouter,
|
|
15
|
+
createMemoryHistory,
|
|
16
|
+
createRootRoute,
|
|
17
|
+
createRoute,
|
|
18
|
+
createRouter,
|
|
19
|
+
RouterContextProvider,
|
|
20
|
+
} from "@tanstack/react-router";
|
|
21
|
+
import { createElement } from "react";
|
|
22
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
23
|
+
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
24
|
+
|
|
25
|
+
const PHONE_WIDTH = 390;
|
|
26
|
+
|
|
27
|
+
let harness: DomHarness | undefined;
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
harness?.close();
|
|
31
|
+
harness = undefined;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const thread = {
|
|
35
|
+
threadMessageId: "tm-1",
|
|
36
|
+
threadId: "th-1",
|
|
37
|
+
messageId: "msg-1",
|
|
38
|
+
accountId: "acc-1",
|
|
39
|
+
accountConfigId: "acc-1",
|
|
40
|
+
mailboxId: "mbx-1",
|
|
41
|
+
subject: "Q3 planning notes",
|
|
42
|
+
fromName: "Alex Rivera",
|
|
43
|
+
fromEmail: "alex@example.com",
|
|
44
|
+
snippet: "Notes from the planning session.",
|
|
45
|
+
sentDate: 0,
|
|
46
|
+
isRead: false,
|
|
47
|
+
hasAttachment: false,
|
|
48
|
+
hasStars: false,
|
|
49
|
+
star: "None",
|
|
50
|
+
isDeleted: false,
|
|
51
|
+
senderTrust: "unknown",
|
|
52
|
+
createdAt: 0,
|
|
53
|
+
updatedAt: 0,
|
|
54
|
+
} as unknown as RemitImapThreadMessageResponse;
|
|
55
|
+
|
|
56
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
57
|
+
// `window`.
|
|
58
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
59
|
+
|
|
60
|
+
const rootRoute = createRootRoute();
|
|
61
|
+
const mailRoute = createRoute({
|
|
62
|
+
getParentRoute: () => rootRoute,
|
|
63
|
+
path: "/mail/$mailboxId",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const testRouter = (): AnyRouter =>
|
|
67
|
+
createRouter({
|
|
68
|
+
routeTree: rootRoute.addChildren([mailRoute]),
|
|
69
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
70
|
+
}) as unknown as AnyRouter;
|
|
71
|
+
|
|
72
|
+
const renderRow = (): DomHarness => {
|
|
73
|
+
const created = createDomHarness({ viewportWidth: PHONE_WIDTH });
|
|
74
|
+
created.render(
|
|
75
|
+
createElement(RouterContextProvider, {
|
|
76
|
+
router: testRouter(),
|
|
77
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
78
|
+
children: createElement(SwipeableMessageRow, {
|
|
79
|
+
thread,
|
|
80
|
+
mailboxId: "mbx-1",
|
|
81
|
+
isSelected: false,
|
|
82
|
+
isChecked: false,
|
|
83
|
+
onToggleCheck: () => undefined,
|
|
84
|
+
onRowSelect: () => false,
|
|
85
|
+
isMultiSelectMode: false,
|
|
86
|
+
onLongPress: () => undefined,
|
|
87
|
+
isDesktop: false,
|
|
88
|
+
onDelete: () => undefined,
|
|
89
|
+
onToggleRead: () => undefined,
|
|
90
|
+
}),
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
return created;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
describe("SwipeableMessageRow — the touch row's open affordance", () => {
|
|
97
|
+
it("renders no anchor at all", () => {
|
|
98
|
+
harness = renderRow();
|
|
99
|
+
|
|
100
|
+
assert.equal(harness.queryAll("a").length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("opens through a button carrying the row marker", () => {
|
|
104
|
+
harness = renderRow();
|
|
105
|
+
|
|
106
|
+
const row = harness.query("button[data-message-row]");
|
|
107
|
+
assert.ok(row, "the row's open affordance is not a button");
|
|
108
|
+
assert.match(row.textContent ?? "", /Q3 planning notes/);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -6,12 +6,11 @@ import {
|
|
|
6
6
|
type SwipePeek,
|
|
7
7
|
type ThreadRowData,
|
|
8
8
|
} from "@remit/ui";
|
|
9
|
-
import {
|
|
9
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
10
10
|
import { useCallback, useState } from "react";
|
|
11
11
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
12
12
|
import { formatEmailDate } from "@/lib/format";
|
|
13
13
|
import { MessageListItem } from "./MessageListItem";
|
|
14
|
-
import { useModifierSelect } from "./useModifierSelect";
|
|
15
14
|
|
|
16
15
|
interface MailboxLinkSearch {
|
|
17
16
|
selectedMessageId?: string;
|
|
@@ -78,6 +77,7 @@ export const SwipeableMessageRow = ({
|
|
|
78
77
|
density,
|
|
79
78
|
}: SwipeableMessageRowProps) => {
|
|
80
79
|
const [peek, setPeek] = useState<SwipePeek>("none");
|
|
80
|
+
const navigate = useNavigate();
|
|
81
81
|
|
|
82
82
|
const handleAct = useCallback(
|
|
83
83
|
(side: "leading" | "trailing") => {
|
|
@@ -100,10 +100,16 @@ export const SwipeableMessageRow = ({
|
|
|
100
100
|
onToggleCheck(thread.messageId);
|
|
101
101
|
}, [onToggleCheck, thread.messageId]);
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
const handleOpen = useCallback(() => {
|
|
104
|
+
navigate({
|
|
105
|
+
to: "/mail/$mailboxId",
|
|
106
|
+
params: { mailboxId },
|
|
107
|
+
search: (prev: MailboxLinkSearch) => ({
|
|
108
|
+
...prev,
|
|
109
|
+
selectedMessageId: thread.messageId,
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
}, [navigate, mailboxId, thread.messageId]);
|
|
107
113
|
|
|
108
114
|
if (isDesktop || isMultiSelectMode) {
|
|
109
115
|
return (
|
|
@@ -135,28 +141,8 @@ export const SwipeableMessageRow = ({
|
|
|
135
141
|
onPeek={setPeek}
|
|
136
142
|
onToggleCheck={handleToggleCheck}
|
|
137
143
|
onLongPress={handleLongPress}
|
|
138
|
-
onOpen={
|
|
144
|
+
onOpen={handleOpen}
|
|
139
145
|
onAct={handleAct}
|
|
140
|
-
linkComponent={({ onOpenClick, children, ...rowProps }) => (
|
|
141
|
-
<Link
|
|
142
|
-
{...rowProps}
|
|
143
|
-
to="/mail/$mailboxId"
|
|
144
|
-
params={{ mailboxId }}
|
|
145
|
-
search={(prev: MailboxLinkSearch) => ({
|
|
146
|
-
...prev,
|
|
147
|
-
selectedMessageId: thread.messageId,
|
|
148
|
-
})}
|
|
149
|
-
data-message-row
|
|
150
|
-
onMouseDown={modifierSelect.onMouseDown}
|
|
151
|
-
onContextMenu={modifierSelect.onContextMenu}
|
|
152
|
-
onClick={(e) => {
|
|
153
|
-
if (modifierSelect.claimClick(e)) return;
|
|
154
|
-
onOpenClick(e);
|
|
155
|
-
}}
|
|
156
|
-
>
|
|
157
|
-
{children}
|
|
158
|
-
</Link>
|
|
159
|
-
)}
|
|
160
146
|
/>
|
|
161
147
|
);
|
|
162
148
|
};
|
|
@@ -1,45 +1,20 @@
|
|
|
1
|
-
import { Button } from "@remit/ui";
|
|
2
1
|
import { Bug } from "lucide-react";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
interface BugReportButtonProps {
|
|
6
|
-
/**
|
|
7
|
-
* `icon` (default) is the compact ghost icon button used in the desktop
|
|
8
|
-
* message toolbar. `drawer` renders a full-width labeled row to sit beside
|
|
9
|
-
* Settings in the mobile drawer footer, where an icon-only control would be
|
|
10
|
-
* unreachable/ambiguous (#685).
|
|
11
|
-
*/
|
|
12
|
-
variant?: "icon" | "drawer";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const openBugReport = () => {
|
|
16
|
-
const ctx = buildBugReportContext();
|
|
17
|
-
const url = buildGitHubIssueUrl(ctx);
|
|
18
|
-
window.open(url, "_blank", "noopener,noreferrer");
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export function BugReportButton({ variant = "icon" }: BugReportButtonProps) {
|
|
22
|
-
if (variant === "drawer") {
|
|
23
|
-
return (
|
|
24
|
-
<button
|
|
25
|
-
type="button"
|
|
26
|
-
onClick={openBugReport}
|
|
27
|
-
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm text-fg-muted transition-colors hover:bg-surface hover:text-fg"
|
|
28
|
-
>
|
|
29
|
-
<Bug className="size-4 shrink-0" />
|
|
30
|
-
<span className="flex-1 truncate text-left">Report a bug</span>
|
|
31
|
-
</button>
|
|
32
|
-
);
|
|
33
|
-
}
|
|
2
|
+
import { openBugReport } from "@/lib/bug-report";
|
|
34
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The mobile drawer's bug-report row — a full-width labeled control beside
|
|
6
|
+
* Settings, where an icon-only button would be unreachable and ambiguous
|
|
7
|
+
* (#685). Above the drawer, the shell's top bar carries this action.
|
|
8
|
+
*/
|
|
9
|
+
export function BugReportButton() {
|
|
35
10
|
return (
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
size="sm"
|
|
39
|
-
icon={<Bug className="size-4" />}
|
|
40
|
-
title="Report a bug"
|
|
41
|
-
aria-label="Report a bug"
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
42
13
|
onClick={openBugReport}
|
|
43
|
-
|
|
14
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm text-fg-muted transition-colors hover:bg-surface hover:text-fg"
|
|
15
|
+
>
|
|
16
|
+
<Bug className="size-4 shrink-0" />
|
|
17
|
+
<span className="flex-1 truncate text-left">Report a bug</span>
|
|
18
|
+
</button>
|
|
44
19
|
);
|
|
45
20
|
}
|
package/src/lib/bug-report.ts
CHANGED
|
@@ -310,3 +310,8 @@ export function buildGitHubIssueUrl(ctx: BugReportContext): string {
|
|
|
310
310
|
|
|
311
311
|
return issueUrl(withoutComponentStack, overrides);
|
|
312
312
|
}
|
|
313
|
+
|
|
314
|
+
export function openBugReport(): void {
|
|
315
|
+
const url = buildGitHubIssueUrl(buildBugReportContext());
|
|
316
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
317
|
+
}
|
package/src/lib/keymap.test.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
KEY_HINT_GROUPS,
|
|
5
|
+
keysForAction,
|
|
6
|
+
shortcutHintForAction,
|
|
7
|
+
tooltipForAction,
|
|
8
|
+
} from "./keymap.ts";
|
|
4
9
|
|
|
5
10
|
describe("keymap module", () => {
|
|
6
11
|
test("exposes the documented groups in reading order", () => {
|
|
@@ -35,6 +40,20 @@ describe("keymap module", () => {
|
|
|
35
40
|
assert.strictEqual(tooltipForAction("compose"), "(c)");
|
|
36
41
|
});
|
|
37
42
|
|
|
43
|
+
test("shortcutHintForAction renders the binding without the parens", () => {
|
|
44
|
+
assert.strictEqual(shortcutHintForAction("reply"), "r");
|
|
45
|
+
assert.strictEqual(shortcutHintForAction("goBrief"), "g then b");
|
|
46
|
+
assert.strictEqual(shortcutHintForAction("compose"), "c");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("an action with no binding gets no hint and no empty parens", () => {
|
|
50
|
+
const unbound = "totallyMissing" as Parameters<
|
|
51
|
+
typeof shortcutHintForAction
|
|
52
|
+
>[0];
|
|
53
|
+
assert.strictEqual(shortcutHintForAction(unbound), "");
|
|
54
|
+
assert.strictEqual(tooltipForAction(unbound), "");
|
|
55
|
+
});
|
|
56
|
+
|
|
38
57
|
test("every hint's action is a non-empty key list", () => {
|
|
39
58
|
for (const group of KEY_HINT_GROUPS) {
|
|
40
59
|
for (const hint of group.hints) {
|
package/src/lib/keymap.ts
CHANGED
|
@@ -188,14 +188,23 @@ export function keysForAction(action: TriageAction): string[] | undefined {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
* Render an action's binding as
|
|
192
|
-
*
|
|
191
|
+
* Render an action's binding as it reads to a user, e.g. "r" or "g then b".
|
|
192
|
+
* Returns "" when the action has no hint.
|
|
193
193
|
*/
|
|
194
|
-
export function
|
|
194
|
+
export function shortcutHintForAction(action: TriageAction): string {
|
|
195
195
|
const keys = keysForAction(action);
|
|
196
196
|
if (!keys || keys.length === 0) return "";
|
|
197
|
-
if (keys.length === 1) return
|
|
197
|
+
if (keys.length === 1) return keys[0] ?? "";
|
|
198
198
|
// Sequence (go-to) keys read as "g then b"; modifier combos as "⌘N".
|
|
199
|
-
if (keys[0] === "g") return
|
|
200
|
-
return
|
|
199
|
+
if (keys[0] === "g") return keys.join(" then ");
|
|
200
|
+
return keys.join("");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render an action's binding as a compact tooltip suffix, e.g. "(r)" or
|
|
205
|
+
* "(g then b)". Returns "" when the action has no hint.
|
|
206
|
+
*/
|
|
207
|
+
export function tooltipForAction(action: TriageAction): string {
|
|
208
|
+
const hint = shortcutHintForAction(action);
|
|
209
|
+
return hint === "" ? "" : `(${hint})`;
|
|
201
210
|
}
|