@botiverse/porthole 0.1.2 → 0.1.3
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/dist/annotate/types.d.ts +120 -0
- package/dist/annotate/types.js +42 -0
- package/dist/annotate/validate.d.ts +11 -0
- package/dist/annotate/validate.js +156 -0
- package/dist/bridge/bridge.d.ts +4 -0
- package/dist/bridge/bridge.js +12 -1
- package/dist/compile/policy.d.ts +4 -3
- package/dist/compile/policy.js +7 -4
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/conformance/clipboard.d.ts +2 -0
- package/dist/conformance/clipboard.js +167 -0
- package/dist/conformance/kit.d.ts +1 -1
- package/dist/conformance/probes.js +2 -0
- package/dist/guide/guide.js +8 -4
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +141 -0
- package/dist/host/types.d.ts +24 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +6 -2
- package/dist/mcp/adapter.js +10 -1
- package/dist/protocol/mcp-apps.d.ts +5 -0
- package/dist/protocol/mcp-apps.js +5 -0
- package/dist/protocol/shell-adapter.js +6 -2
- package/dist/react/dialog.js +2 -0
- package/dist/react/frame.d.ts +18 -0
- package/dist/react/frame.js +82 -2
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/toast.d.ts +33 -0
- package/dist/react/toast.js +42 -0
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +669 -0
- package/dist/shell/document.js +4 -1
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +2 -1
- package/dist/shell/runtime.d.ts +4 -1
- package/dist/shell/runtime.js +38 -2
- package/package.json +1 -1
- package/src/annotate/types.ts +126 -0
- package/src/annotate/validate.ts +186 -0
- package/src/bridge/bridge.ts +13 -0
- package/src/compile/policy.ts +7 -4
- package/src/config/capabilities.ts +33 -3
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/guide/guide.ts +19 -4
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +155 -0
- package/src/host/types.ts +25 -0
- package/src/index.ts +22 -0
- package/src/mcp/adapter.ts +10 -1
- package/src/protocol/mcp-apps.ts +6 -0
- package/src/protocol/shell-adapter.ts +11 -2
- package/src/react/dialog.tsx +2 -0
- package/src/react/frame.tsx +117 -1
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/shell/annotate.ts +669 -0
- package/src/shell/document.ts +6 -1
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +38 -2
package/src/react/dialog.tsx
CHANGED
|
@@ -66,6 +66,8 @@ function words(request: ConfirmRequest): Words {
|
|
|
66
66
|
return { heading: "Share this with the agent?", accept: "Share", detail: json(request.args) };
|
|
67
67
|
case "readData":
|
|
68
68
|
return { heading: "Let it read this?", accept: "Allow", detail: request.args.uri };
|
|
69
|
+
case "copyText":
|
|
70
|
+
return { heading: "Copy this text?", accept: "Copy", detail: request.args.text };
|
|
69
71
|
default:
|
|
70
72
|
return { heading: "Allow this?", accept: "Allow", detail: json(request.args) };
|
|
71
73
|
}
|
package/src/react/frame.tsx
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
type ReactElement,
|
|
15
15
|
type ReactNode,
|
|
16
16
|
} from "react";
|
|
17
|
+
import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
|
|
17
18
|
import type {
|
|
18
19
|
CallContext,
|
|
20
|
+
CapabilityHandler,
|
|
19
21
|
CapabilityHandlers,
|
|
20
22
|
Confirm,
|
|
21
23
|
ConfirmAnswer,
|
|
@@ -25,11 +27,13 @@ import type {
|
|
|
25
27
|
} from "../bridge/types.ts";
|
|
26
28
|
import type { CapabilityName } from "../config/capabilities.ts";
|
|
27
29
|
import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
|
|
30
|
+
import { writeClipboard } from "../host/clipboard.ts";
|
|
28
31
|
import { createHost } from "../host/create-host.ts";
|
|
29
32
|
import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
|
|
30
33
|
import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
|
|
31
34
|
import { ConfirmDialog } from "./dialog.tsx";
|
|
32
35
|
import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
|
|
36
|
+
import { CopyToast, type CopyNotice } from "./toast.tsx";
|
|
33
37
|
|
|
34
38
|
/**
|
|
35
39
|
* The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
|
|
@@ -50,6 +54,8 @@ interface PageState {
|
|
|
50
54
|
readonly confirm?: Confirm;
|
|
51
55
|
readonly hostInfo?: { readonly name: string; readonly version: string };
|
|
52
56
|
readonly locator?: InstanceLocator;
|
|
57
|
+
/** The default `copyText`: copies on the host's page and shows the toast (P052). */
|
|
58
|
+
readonly copyText?: CapabilityHandler<"copyText">;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
/** Without a provider: one registry for the page, and no one to confirm (gated calls refused). */
|
|
@@ -69,6 +75,8 @@ export interface PortholeProviderProps {
|
|
|
69
75
|
readonly locator?: InstanceLocator;
|
|
70
76
|
/** A class for the default dialog, to restyle it. */
|
|
71
77
|
readonly dialogClassName?: string;
|
|
78
|
+
/** A class for the default copy toast, to restyle it. */
|
|
79
|
+
readonly toastClassName?: string;
|
|
72
80
|
readonly children?: ReactNode;
|
|
73
81
|
}
|
|
74
82
|
|
|
@@ -96,16 +104,26 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
|
96
104
|
),
|
|
97
105
|
[],
|
|
98
106
|
);
|
|
107
|
+
// The default copyText: the host's page writes the clipboard (in the frame's click, which
|
|
108
|
+
// activated this page too) and shows the person what went on it, or offers to copy it.
|
|
109
|
+
const [notice, setNotice] = useState<CopyNotice | null>(null);
|
|
110
|
+
const copyText = useCallback<CapabilityHandler<"copyText">>(async ({ text }, context) => {
|
|
111
|
+
const copied = await writeClipboard(text, globalThis.document);
|
|
112
|
+
setNotice({ text, copied, title: context.title });
|
|
113
|
+
return copied;
|
|
114
|
+
}, []);
|
|
115
|
+
const closeNotice = useCallback(() => setNotice(null), []);
|
|
99
116
|
const { hostInfo, locator } = props;
|
|
100
117
|
const value = useMemo<PageState>(
|
|
101
118
|
() => ({
|
|
102
119
|
live,
|
|
103
120
|
heights,
|
|
104
121
|
confirm,
|
|
122
|
+
copyText,
|
|
105
123
|
...(hostInfo === undefined ? {} : { hostInfo }),
|
|
106
124
|
...(locator === undefined ? {} : { locator }),
|
|
107
125
|
}),
|
|
108
|
-
[live, heights, confirm, hostInfo, locator],
|
|
126
|
+
[live, heights, confirm, copyText, hostInfo, locator],
|
|
109
127
|
);
|
|
110
128
|
const current = queue[0] ?? null;
|
|
111
129
|
return (
|
|
@@ -125,6 +143,17 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
|
125
143
|
}}
|
|
126
144
|
/>
|
|
127
145
|
) : null}
|
|
146
|
+
<CopyToast
|
|
147
|
+
notice={notice}
|
|
148
|
+
{...(props.toastClassName === undefined ? {} : { className: props.toastClassName })}
|
|
149
|
+
onClose={closeNotice}
|
|
150
|
+
onCopy={(failed) => {
|
|
151
|
+
// A click on the host's own page: the write has the gesture it needs now.
|
|
152
|
+
void writeClipboard(failed.text, globalThis.document).then((copied) =>
|
|
153
|
+
setNotice({ ...failed, copied }),
|
|
154
|
+
);
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
128
157
|
</Page.Provider>
|
|
129
158
|
);
|
|
130
159
|
}
|
|
@@ -186,6 +215,21 @@ export interface PortholeFrameProps {
|
|
|
186
215
|
* restyle): the running frame reads them again.
|
|
187
216
|
*/
|
|
188
217
|
readonly themeKey?: string | number;
|
|
218
|
+
/**
|
|
219
|
+
* Annotations, where the config declares `annotate` (P053): what the person may point at now
|
|
220
|
+
* (elements, text, or both), or null to stop. A picked element ends element picking: set it
|
|
221
|
+
* again to pick again.
|
|
222
|
+
*/
|
|
223
|
+
readonly annotating?: AnnotateMode | null;
|
|
224
|
+
/** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
|
|
225
|
+
readonly annotations?: readonly AnnotationMark[];
|
|
226
|
+
/** The mark to bring into view and make the active one; each new id focuses it. */
|
|
227
|
+
readonly focusedAnnotation?: string | null;
|
|
228
|
+
/**
|
|
229
|
+
* What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
|
|
230
|
+
* pick, where marks are. The content's word: UI hints, never permissions.
|
|
231
|
+
*/
|
|
232
|
+
readonly onAnnotation?: (report: AnnotationReport) => void;
|
|
189
233
|
/** Draws the placeholder instead of the default one. */
|
|
190
234
|
readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
|
|
191
235
|
readonly className?: string;
|
|
@@ -371,6 +415,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
371
415
|
case "outcome":
|
|
372
416
|
frame.onOutcome?.(detail as DisplayEvents["outcome"]);
|
|
373
417
|
return;
|
|
418
|
+
case "annotation":
|
|
419
|
+
frame.onAnnotation?.(detail as DisplayEvents["annotation"]);
|
|
420
|
+
return;
|
|
374
421
|
}
|
|
375
422
|
},
|
|
376
423
|
);
|
|
@@ -397,6 +444,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
397
444
|
named(context),
|
|
398
445
|
);
|
|
399
446
|
}
|
|
447
|
+
// Copy buttons without a handler of the host's: the provider's, which shows a toast.
|
|
448
|
+
if (config.capabilities.copyText !== undefined && handlers.copyText === undefined) {
|
|
449
|
+
const fallback = page.copyText;
|
|
450
|
+
if (fallback !== undefined) {
|
|
451
|
+
handlers.copyText = (args, context) =>
|
|
452
|
+
fallback(args as { readonly text: string }, named(context));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
400
455
|
const asks = first.confirm !== undefined || page.confirm !== undefined;
|
|
401
456
|
let display: DisplayHandle;
|
|
402
457
|
try {
|
|
@@ -435,10 +490,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
435
490
|
"refused",
|
|
436
491
|
"muted",
|
|
437
492
|
"outcome",
|
|
493
|
+
"annotation",
|
|
438
494
|
];
|
|
439
495
|
for (const event of events) {
|
|
440
496
|
display.on(event, (detail) => notify(event, detail));
|
|
441
497
|
}
|
|
498
|
+
if (config.capabilities.annotate !== undefined) {
|
|
499
|
+
applyAnnotations(display, first);
|
|
500
|
+
}
|
|
442
501
|
if (first.content !== undefined) {
|
|
443
502
|
display.setContent(first.content, { partial: first.partial === true });
|
|
444
503
|
}
|
|
@@ -455,6 +514,39 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
455
514
|
handle.current?.setContent(content, { partial: partial === true });
|
|
456
515
|
}
|
|
457
516
|
}, [content, partial]);
|
|
517
|
+
// Annotations follow their props (a new display gets them all when it is made, above).
|
|
518
|
+
const annotates = config.capabilities.annotate !== undefined;
|
|
519
|
+
const { annotating, annotations, focusedAnnotation } = frame;
|
|
520
|
+
const mode =
|
|
521
|
+
annotating === undefined || annotating === null
|
|
522
|
+
? ""
|
|
523
|
+
: JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
const display = handle.current;
|
|
526
|
+
if (!annotates || display === null) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const wanted = mode === "" ? null : (JSON.parse(mode) as [boolean, boolean]);
|
|
530
|
+
attempt(
|
|
531
|
+
() =>
|
|
532
|
+
wanted === null
|
|
533
|
+
? display.annotations.stop()
|
|
534
|
+
: display.annotations.start({ elements: wanted[0], text: wanted[1] }),
|
|
535
|
+
latest(),
|
|
536
|
+
);
|
|
537
|
+
}, [annotates, mode]);
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
const display = handle.current;
|
|
540
|
+
if (annotates && display !== null) {
|
|
541
|
+
attempt(() => display.annotations.show(annotations ?? []), latest());
|
|
542
|
+
}
|
|
543
|
+
}, [annotates, annotations]);
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
const display = handle.current;
|
|
546
|
+
if (annotates && display !== null && typeof focusedAnnotation === "string") {
|
|
547
|
+
attempt(() => display.annotations.focus(focusedAnnotation), latest());
|
|
548
|
+
}
|
|
549
|
+
}, [annotates, focusedAnnotation]);
|
|
458
550
|
useEffect(() => {
|
|
459
551
|
handle.current?.setTheme(scheme);
|
|
460
552
|
}, [scheme]);
|
|
@@ -484,3 +576,27 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
484
576
|
</div>
|
|
485
577
|
);
|
|
486
578
|
}
|
|
579
|
+
|
|
580
|
+
/** Runs an annotation call, telling the frame's `onError` what it threw (a bad anchor, say). */
|
|
581
|
+
function attempt(run: () => void, frame: PortholeFrameProps): void {
|
|
582
|
+
try {
|
|
583
|
+
run();
|
|
584
|
+
} catch (error) {
|
|
585
|
+
frame.onError?.(error instanceof Error ? error.message : String(error));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** A new display's annotations, from the props as they are. */
|
|
590
|
+
function applyAnnotations(display: DisplayHandle, frame: PortholeFrameProps): void {
|
|
591
|
+
attempt(() => {
|
|
592
|
+
if (frame.annotations !== undefined && frame.annotations.length > 0) {
|
|
593
|
+
display.annotations.show(frame.annotations);
|
|
594
|
+
}
|
|
595
|
+
if (typeof frame.focusedAnnotation === "string") {
|
|
596
|
+
display.annotations.focus(frame.focusedAnnotation);
|
|
597
|
+
}
|
|
598
|
+
if (frame.annotating !== undefined && frame.annotating !== null) {
|
|
599
|
+
display.annotations.start(frame.annotating);
|
|
600
|
+
}
|
|
601
|
+
}, frame);
|
|
602
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
|
|
3
3
|
* package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
|
|
4
4
|
*
|
|
5
|
-
* <PortholeProvider> // per page: live-frame cap, heights, the dialog
|
|
5
|
+
* <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
|
|
6
6
|
* <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
|
|
7
7
|
* content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
|
|
8
8
|
* theme={tokensFor} onError={report} />
|
|
@@ -17,4 +17,12 @@ export {
|
|
|
17
17
|
type PortholeProviderProps,
|
|
18
18
|
} from "./frame.tsx";
|
|
19
19
|
export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
|
|
20
|
+
export {
|
|
21
|
+
CopyToast,
|
|
22
|
+
TOAST_CHARS,
|
|
23
|
+
TOAST_MS,
|
|
24
|
+
clipped,
|
|
25
|
+
type CopyNotice,
|
|
26
|
+
type CopyToastProps,
|
|
27
|
+
} from "./toast.tsx";
|
|
20
28
|
export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useEffect, type ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The default copy notice (P052): when content's `copyText` copied something, the host's page
|
|
5
|
+
* shows the person what went on their clipboard, clipped, in a small toast outside every frame.
|
|
6
|
+
* If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
|
|
7
|
+
* host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
|
|
8
|
+
* styles it through the `porthole-toast` class and data attributes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How long a toast for a copy that worked stays. */
|
|
12
|
+
export const TOAST_MS = 5_000;
|
|
13
|
+
/** The most of the copied text a toast shows. */
|
|
14
|
+
export const TOAST_CHARS = 280;
|
|
15
|
+
const TOAST_LINES = 4;
|
|
16
|
+
|
|
17
|
+
export interface CopyNotice {
|
|
18
|
+
/** The whole text copied (or to copy). */
|
|
19
|
+
readonly text: string;
|
|
20
|
+
/** Whether it is on the clipboard. */
|
|
21
|
+
readonly copied: boolean;
|
|
22
|
+
/** The display's title, as the host named it. */
|
|
23
|
+
readonly title: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CopyToastProps {
|
|
27
|
+
readonly notice: CopyNotice | null;
|
|
28
|
+
/** The person asked to copy it again (it failed), from this click. */
|
|
29
|
+
readonly onCopy: (notice: CopyNotice) => void;
|
|
30
|
+
readonly onClose: () => void;
|
|
31
|
+
readonly className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
|
|
35
|
+
export function clipped(text: string): { readonly shown: string; readonly clipped: boolean } {
|
|
36
|
+
const lines = text.split("\n");
|
|
37
|
+
let shown = lines.slice(0, TOAST_LINES).join("\n");
|
|
38
|
+
if (shown.length > TOAST_CHARS) {
|
|
39
|
+
shown = shown.slice(0, TOAST_CHARS);
|
|
40
|
+
}
|
|
41
|
+
return shown.length < text.length
|
|
42
|
+
? { shown: `${shown}…`, clipped: true }
|
|
43
|
+
: { shown, clipped: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const STYLE = `.porthole-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:12px 14px;width:min(420px,calc(100vw - 32px));box-shadow:0 8px 28px rgb(0 0 0 / .25);font:inherit;font-size:.9em}
|
|
47
|
+
.porthole-toast p{margin:0 0 6px}
|
|
48
|
+
.porthole-toast [data-porthole-source]{opacity:.72}
|
|
49
|
+
.porthole-toast pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:8em;overflow:hidden;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
50
|
+
.porthole-toast menu{display:flex;justify-content:flex-end;gap:8px;margin:8px 0 0;padding:0}
|
|
51
|
+
.porthole-toast button{font:inherit;min-height:32px;padding:0 12px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}`;
|
|
52
|
+
|
|
53
|
+
export function CopyToast({ notice, onCopy, onClose, className }: CopyToastProps): ReactElement {
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (notice === null || !notice.copied) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const timer = setTimeout(onClose, TOAST_MS);
|
|
59
|
+
return () => clearTimeout(timer);
|
|
60
|
+
}, [notice, onClose]);
|
|
61
|
+
const view = notice === null ? null : clipped(notice.text);
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
role="status"
|
|
65
|
+
aria-live="polite"
|
|
66
|
+
className={className === undefined ? "porthole-toast" : `porthole-toast ${className}`}
|
|
67
|
+
data-porthole-toast=""
|
|
68
|
+
data-porthole-copied={notice === null ? "" : String(notice.copied)}
|
|
69
|
+
hidden={notice === null}
|
|
70
|
+
>
|
|
71
|
+
<style>{STYLE}</style>
|
|
72
|
+
{notice === null || view === null ? null : (
|
|
73
|
+
<>
|
|
74
|
+
<p>
|
|
75
|
+
<strong>{notice.copied ? "Copied" : "Not copied yet"}</strong>{" "}
|
|
76
|
+
<span data-porthole-source="">{`from “${notice.title}”`}</span>
|
|
77
|
+
</p>
|
|
78
|
+
<pre data-porthole-copied-text="">{view.shown}</pre>
|
|
79
|
+
{view.clipped ? (
|
|
80
|
+
<p data-porthole-length="">{`${notice.text.length.toLocaleString("en-US")} characters in all`}</p>
|
|
81
|
+
) : null}
|
|
82
|
+
<menu>
|
|
83
|
+
{notice.copied ? null : (
|
|
84
|
+
<button type="button" data-porthole-copy="" onClick={() => onCopy(notice)}>
|
|
85
|
+
Copy
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
<button type="button" data-porthole-close="" onClick={onClose}>
|
|
89
|
+
{notice.copied ? "OK" : "Close"}
|
|
90
|
+
</button>
|
|
91
|
+
</menu>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|