Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/components/AnnotationShareControl.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CheckIcon,
CopyIcon,
IconButton,
Input,
Expand All @@ -11,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import type { Group } from '../helpers';
import type { Annotation } from '../helpers/annotation-metadata';
import { isPrivate } from '../helpers/permissions';
import { useTimeout } from '../hooks/use-timeout';
import type { Result } from '../utils/types';
import { isIOS } from '../utils/user-agent';

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

const [copied, setCopied] = useState(false);
const setTimeout = useTimeout();
const copyShareLink = useCallback(async () => {
try {
await navigator.clipboard.writeText(shareURI!);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
onCopy?.({ ok: true, value: shareURI! });
} catch (error: any) {
onCopy?.({ ok: false, error });
}
}, [onCopy, shareURI]);
}, [onCopy, setTimeout, shareURI]);

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

Expand Down Expand Up @@ -160,7 +166,7 @@ export default function AnnotationShareControl({
elementRef={inputRef}
/>
<IconButton
icon={CopyIcon}
icon={copied ? CheckIcon : CopyIcon}
title="Copy share link to clipboard"
onClick={copyShareLink}
variant="dark"
Expand Down
27 changes: 27 additions & 0 deletions src/components/test/AnnotationShareControl-test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
checkAccessibility,
delay,
mockImportedComponents,
} from '@hypothesis/frontend-testing';
import { mount } from '@hypothesis/frontend-testing';
Expand All @@ -13,6 +14,7 @@ describe('AnnotationShareControl', () => {
let fakeGroup;
let fakeIsPrivate;
let fakeIsIOS;
let fakeUseTimeout;

const getIconButton = (wrapper, iconName) => {
return wrapper
Expand Down Expand Up @@ -63,10 +65,17 @@ describe('AnnotationShareControl', () => {
fakeIsPrivate = sinon.stub().returns(false);
fakeIsIOS = sinon.stub().returns(false);

fakeUseTimeout = sinon
.stub()
.returns(sinon.stub().callsFake(callback => setTimeout(callback, 0)));

$imports.$mock(mockImportedComponents());
$imports.$mock({
'../helpers/permissions': { isPrivate: fakeIsPrivate },
'../utils/user-agent': { isIOS: fakeIsIOS },
'../hooks/use-timeout': {
useTimeout: fakeUseTimeout,
},
});
});

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

assert.calledWith(onCopy, { ok: false, error });
});

it('replaces copy icon with check and eventually goes back to the copy icon', async () => {
const wrapper = createComponent({ onCopy });
openElement(wrapper);

await getIconButton(wrapper, 'CopyIcon').props().onClick();
wrapper.update();

assert.isTrue(getIconButton(wrapper, 'CheckIcon').exists());
assert.isFalse(getIconButton(wrapper, 'CopyIcon').exists());

// Once the timeout clears, the copy icon will be restored
await delay(0);
wrapper.update();

assert.isFalse(getIconButton(wrapper, 'CheckIcon').exists());
assert.isTrue(getIconButton(wrapper, 'CopyIcon').exists());
});
});

[
Expand Down
50 changes: 50 additions & 0 deletions src/hooks/test/use-timeout-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { delay, mount } from '@hypothesis/frontend-testing';
import sinon from 'sinon';

import { useTimeout } from '../use-timeout';

describe('useTimeout', () => {
let fakeSetTimeout;
let fakeClearTimeout;
let currentSetTimeout;

beforeEach(() => {
fakeSetTimeout = sinon
.stub()
.callsFake(callback => setTimeout(callback, 0));
fakeClearTimeout = sinon.stub();
currentSetTimeout = undefined;
});

function TestComponent() {
currentSetTimeout = useTimeout(fakeSetTimeout, fakeClearTimeout);
return null;
}

function createComponent() {
return mount(<TestComponent />);
}

it('calls setTimeout when returned setTimeout-like callback is invoked', async () => {
const callback = sinon.stub();
createComponent();

assert.notCalled(fakeSetTimeout);
currentSetTimeout(callback, 500);
assert.calledWith(fakeSetTimeout, sinon.match.func, 500);

// The callback passed to the timeout will also be called after the timeout
await delay(0);
assert.called(callback);
});

it('clears current timeout when unmounted', () => {
fakeSetTimeout.returns(1);
const wrapper = createComponent();

currentSetTimeout(sinon.stub(), 500);

wrapper.unmount();
assert.called(fakeClearTimeout);
});
});
38 changes: 38 additions & 0 deletions src/hooks/use-timeout.ts
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to extract this hook somewhere else, as it's a pattern we have used in a couple of places already.

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useRef } from 'preact/hooks';

/**
* Returns a setTimeout-compatible stable function, which automatically clears
* the previously scheduled callback every time it's called.
*
* The timeout instance is not returned, as the hook internally handles
* clearing it.
*/
export function useTimeout(
/* istanbul ignore next - test seam */
setTimeout_: typeof setTimeout = setTimeout,
/* istanbul ignore next - test seam */
clearTimeout_: typeof clearTimeout = clearTimeout,
): (callback: () => void, delay: number) => void {
const timeoutRef = useRef<ReturnType<typeof setTimeout_> | null>(null);
const clearCurrentTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout_(timeoutRef.current);
}
}, [clearTimeout_]);

// When unmounted, clear the last timeout, if any
useEffect(() => {
return clearCurrentTimeout;
}, [clearCurrentTimeout]);

return useCallback(
(callback, delay) => {
clearCurrentTimeout();
timeoutRef.current = setTimeout_(() => {
callback();
timeoutRef.current = null;
}, delay);
},
[clearCurrentTimeout, setTimeout_],
);
}