Skip to content

Commit 6eb440a

Browse files
committed
Briefly display a tick after copying an annotation share URL
1 parent 6bc97ad commit 6eb440a

File tree

4 files changed

+123
-2
lines changed

4 files changed

+123
-2
lines changed

src/components/AnnotationShareControl.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
CheckIcon,
23
CopyIcon,
34
IconButton,
45
Input,
@@ -11,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
1112
import type { Group } from '../helpers';
1213
import type { Annotation } from '../helpers/annotation-metadata';
1314
import { isPrivate } from '../helpers/permissions';
15+
import { useTimeout } from '../hooks/use-timeout';
1416
import type { Result } from '../utils/types';
1517
import { isIOS } from '../utils/user-agent';
1618

@@ -75,14 +77,18 @@ export default function AnnotationShareControl({
7577
const [isOpen, setOpen] = useState(false);
7678
const wasOpen = useRef(isOpen);
7779

80+
const [copied, setCopied] = useState(false);
81+
const setTimeout = useTimeout();
7882
const copyShareLink = useCallback(async () => {
7983
try {
8084
await navigator.clipboard.writeText(shareURI!);
85+
setCopied(true);
86+
setTimeout(() => setCopied(false), 1500);
8187
onCopy?.({ ok: true, value: shareURI! });
8288
} catch (error: any) {
8389
onCopy?.({ ok: false, error });
8490
}
85-
}, [onCopy, shareURI]);
91+
}, [onCopy, setTimeout, shareURI]);
8692

8793
const toggleSharePanel = () => setOpen(prev => !prev);
8894

@@ -160,7 +166,7 @@ export default function AnnotationShareControl({
160166
elementRef={inputRef}
161167
/>
162168
<IconButton
163-
icon={CopyIcon}
169+
icon={copied ? CheckIcon : CopyIcon}
164170
title="Copy share link to clipboard"
165171
onClick={copyShareLink}
166172
variant="dark"

src/components/test/AnnotationShareControl-test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
checkAccessibility,
3+
delay,
34
mockImportedComponents,
45
} from '@hypothesis/frontend-testing';
56
import { mount } from '@hypothesis/frontend-testing';
@@ -13,6 +14,7 @@ describe('AnnotationShareControl', () => {
1314
let fakeGroup;
1415
let fakeIsPrivate;
1516
let fakeIsIOS;
17+
let fakeUseTimeout;
1618

1719
const getIconButton = (wrapper, iconName) => {
1820
return wrapper
@@ -63,10 +65,17 @@ describe('AnnotationShareControl', () => {
6365
fakeIsPrivate = sinon.stub().returns(false);
6466
fakeIsIOS = sinon.stub().returns(false);
6567

68+
fakeUseTimeout = sinon
69+
.stub()
70+
.returns(sinon.stub().callsFake(callback => setTimeout(callback, 0)));
71+
6672
$imports.$mock(mockImportedComponents());
6773
$imports.$mock({
6874
'../helpers/permissions': { isPrivate: fakeIsPrivate },
6975
'../utils/user-agent': { isIOS: fakeIsIOS },
76+
'../hooks/use-timeout': {
77+
useTimeout: fakeUseTimeout,
78+
},
7079
});
7180
});
7281

@@ -147,6 +156,24 @@ describe('AnnotationShareControl', () => {
147156

148157
assert.calledWith(onCopy, { ok: false, error });
149158
});
159+
160+
it('replaces copy icon with check and eventually goes back to the copy icon', async () => {
161+
const wrapper = createComponent({ onCopy });
162+
openElement(wrapper);
163+
164+
await getIconButton(wrapper, 'CopyIcon').props().onClick();
165+
wrapper.update();
166+
167+
assert.isTrue(getIconButton(wrapper, 'CheckIcon').exists());
168+
assert.isFalse(getIconButton(wrapper, 'CopyIcon').exists());
169+
170+
// Once the timeout clears, the copy icon will be restored
171+
await delay(0);
172+
wrapper.update();
173+
174+
assert.isFalse(getIconButton(wrapper, 'CheckIcon').exists());
175+
assert.isTrue(getIconButton(wrapper, 'CopyIcon').exists());
176+
});
150177
});
151178

152179
[

src/hooks/test/use-timeout-test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { delay, mount } from '@hypothesis/frontend-testing';
2+
import sinon from 'sinon';
3+
4+
import { useTimeout } from '../use-timeout';
5+
6+
describe('useTimeout', () => {
7+
let fakeSetTimeout;
8+
let fakeClearTimeout;
9+
let currentSetTimeout;
10+
11+
beforeEach(() => {
12+
fakeSetTimeout = sinon
13+
.stub()
14+
.callsFake(callback => setTimeout(callback, 0));
15+
fakeClearTimeout = sinon.stub();
16+
currentSetTimeout = undefined;
17+
});
18+
19+
function TestComponent() {
20+
currentSetTimeout = useTimeout(fakeSetTimeout, fakeClearTimeout);
21+
return null;
22+
}
23+
24+
function createComponent() {
25+
return mount(<TestComponent />);
26+
}
27+
28+
it('calls setTimeout when returned setTimeout-like callback is invoked', async () => {
29+
const callback = sinon.stub();
30+
createComponent();
31+
32+
assert.notCalled(fakeSetTimeout);
33+
currentSetTimeout(callback, 500);
34+
assert.calledWith(fakeSetTimeout, sinon.match.func, 500);
35+
36+
// The callback passed to the timeout will also be called after the timeout
37+
await delay(0);
38+
assert.called(callback);
39+
});
40+
41+
it('clears current timeout when unmounted', () => {
42+
fakeSetTimeout.returns(1);
43+
const wrapper = createComponent();
44+
45+
currentSetTimeout(sinon.stub(), 500);
46+
47+
wrapper.unmount();
48+
assert.called(fakeClearTimeout);
49+
});
50+
});

src/hooks/use-timeout.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useCallback, useEffect, useRef } from 'preact/hooks';
2+
3+
/**
4+
* Returns a setTimeout-compatible stable function, which automatically clears
5+
* the previously scheduled callback every time it's called.
6+
*
7+
* The timeout instance is not returned, as the hook internally handles
8+
* clearing it.
9+
*/
10+
export function useTimeout(
11+
/* istanbul ignore next - test seam */
12+
setTimeout_: typeof setTimeout = setTimeout,
13+
/* istanbul ignore next - test seam */
14+
clearTimeout_: typeof clearTimeout = clearTimeout,
15+
): (callback: () => void, delay: number) => void {
16+
const timeoutRef = useRef<ReturnType<typeof setTimeout_> | null>(null);
17+
const clearCurrentTimeout = useCallback(() => {
18+
if (timeoutRef.current) {
19+
clearTimeout_(timeoutRef.current);
20+
}
21+
}, [clearTimeout_]);
22+
23+
// When unmounted, clear the last timeout, if any
24+
useEffect(() => {
25+
return clearCurrentTimeout;
26+
}, [clearCurrentTimeout]);
27+
28+
return useCallback(
29+
(callback, delay) => {
30+
clearCurrentTimeout();
31+
timeoutRef.current = setTimeout_(() => {
32+
callback();
33+
timeoutRef.current = null;
34+
}, delay);
35+
},
36+
[clearCurrentTimeout, setTimeout_],
37+
);
38+
}

0 commit comments

Comments
 (0)