From 429da0219fc7e540991d9817faec7a25ade87047 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 27 Jun 2025 12:58:31 +0200 Subject: [PATCH] Briefly display a tick after copying an annotation share URL --- src/components/AnnotationShareControl.tsx | 10 +++- .../test/AnnotationShareControl-test.js | 27 ++++++++++ src/hooks/test/use-timeout-test.js | 50 +++++++++++++++++++ src/hooks/use-timeout.ts | 38 ++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/hooks/test/use-timeout-test.js create mode 100644 src/hooks/use-timeout.ts diff --git a/src/components/AnnotationShareControl.tsx b/src/components/AnnotationShareControl.tsx index aa3cd49..abe9900 100644 --- a/src/components/AnnotationShareControl.tsx +++ b/src/components/AnnotationShareControl.tsx @@ -1,4 +1,5 @@ import { + CheckIcon, CopyIcon, IconButton, Input, @@ -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'; @@ -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); @@ -160,7 +166,7 @@ export default function AnnotationShareControl({ elementRef={inputRef} /> { let fakeGroup; let fakeIsPrivate; let fakeIsIOS; + let fakeUseTimeout; const getIconButton = (wrapper, iconName) => { return wrapper @@ -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, + }, }); }); @@ -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()); + }); }); [ diff --git a/src/hooks/test/use-timeout-test.js b/src/hooks/test/use-timeout-test.js new file mode 100644 index 0000000..dd88e6c --- /dev/null +++ b/src/hooks/test/use-timeout-test.js @@ -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(); + } + + 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); + }); +}); diff --git a/src/hooks/use-timeout.ts b/src/hooks/use-timeout.ts new file mode 100644 index 0000000..fa2243b --- /dev/null +++ b/src/hooks/use-timeout.ts @@ -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 | 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_], + ); +}