From df1cf7dd2ef6176a863b8f6cbf0e582a3d860642 Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 23:31:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT]=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=AA=A8=EB=8B=AC=EC=97=90=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EA=B3=B5=EC=9C=A0=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QR 코드 아래에 공유 URL을 클립보드에 복사하는 버튼 추가 - 복사 성공/실패를 버튼 문구로 2초간 표시 - Clipboard API 미지원·실패 시 execCommand('copy')로 폴백하는 copyToClipboard 유틸 추가 Co-Authored-By: Claude Opus 5.5 --- public/locales/en/translation.json | 5 +- public/locales/ko/translation.json | 5 +- .../components/LiveShareModal.test.tsx | 73 ++++++++++++++++++- .../TimerPage/components/LiveShareModal.tsx | 42 ++++++++++- src/util/clipboard.test.ts | 63 ++++++++++++++++ src/util/clipboard.ts | 35 +++++++++ 6 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 src/util/clipboard.test.ts create mode 100644 src/util/clipboard.ts diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 54107c56..2c652aaa 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -316,5 +316,8 @@ "라이브 연결 끊김": "Live sharing disconnected", "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "The connection to the live server was lost. Please refresh the page.", "필요한 데이터를 불러오지 못했어요.": "We couldn't load the data we need.", - "다시 시도해보세요.": "Please try again." + "다시 시도해보세요.": "Please try again.", + "링크 공유": "Share Link", + "링크 복사됨": "Link Copied", + "링크 복사 실패": "Copy Failed" } diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index d6a07fa8..1b7bded6 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -316,5 +316,8 @@ "라이브 연결 끊김": "라이브 연결 끊김", "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.", "필요한 데이터를 불러오지 못했어요.": "필요한 데이터를 불러오지 못했어요.", - "다시 시도해보세요.": "다시 시도해보세요." + "다시 시도해보세요.": "다시 시도해보세요.", + "링크 공유": "링크 공유", + "링크 복사됨": "링크 복사됨", + "링크 복사 실패": "링크 복사 실패" } diff --git a/src/page/TimerPage/components/LiveShareModal.test.tsx b/src/page/TimerPage/components/LiveShareModal.test.tsx index 0330375e..b1e8e19b 100644 --- a/src/page/TimerPage/components/LiveShareModal.test.tsx +++ b/src/page/TimerPage/components/LiveShareModal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ComponentProps } from 'react'; import { vi } from 'vitest'; @@ -22,6 +22,9 @@ const ERROR_TITLE = '라이브 공유 불가'; const TOKEN_ERROR_MESSAGE = '사회자 인증 토큰 발급에 실패했어요...'; const LIVE_SERVER_ERROR_MESSAGE = '라이브 서버 연결에 실패했어요...'; const CLOSE_LABEL = '모달 닫기'; +const COPY_LINK_LABEL = '링크 공유'; +const COPIED_LABEL = '링크 복사됨'; +const COPY_FAILED_LABEL = '링크 복사 실패'; const QR_DESCRIPTION = '휴대폰 카메라로 QR 코드를 스캔하면 토론 타이머 화면이 자동으로 열립니다.'; @@ -140,4 +143,72 @@ describe('LiveShareModal', () => { screen.getByText('라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.'), ).toBeInTheDocument(); }); + + test('링크 공유 버튼을 클릭하면 QR 코드와 같은 URL을 클립보드에 복사한다', async () => { + const user = userEvent.setup(); + + renderLiveShareModal(); + + await user.click(screen.getByRole('button', { name: COPY_LINK_LABEL })); + + expect(screen.getByLabelText('qr-code')).toHaveAttribute( + 'data-value', + SHARE_URL, + ); + await expect(navigator.clipboard.readText()).resolves.toBe(SHARE_URL); + expect( + screen.getByRole('button', { name: COPIED_LABEL }), + ).toBeInTheDocument(); + }); + + test('복사 완료 표시는 잠시 후 원래 버튼 문구로 돌아간다', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + try { + renderLiveShareModal(); + + await user.click(screen.getByRole('button', { name: COPY_LINK_LABEL })); + expect( + screen.getByRole('button', { name: COPIED_LABEL }), + ).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect( + screen.getByRole('button', { name: COPY_LINK_LABEL }), + ).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + test('클립보드 복사에 실패하면 실패 문구를 보여준다', async () => { + const user = userEvent.setup(); + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue( + new Error('denied'), + ); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(false), + }); + + renderLiveShareModal(); + + await user.click(screen.getByRole('button', { name: COPY_LINK_LABEL })); + + expect( + screen.getByRole('button', { name: COPY_FAILED_LABEL }), + ).toBeInTheDocument(); + }); + + test('오류 상태에서는 링크 공유 버튼을 보여주지 않는다', () => { + renderLiveShareModal({ isError: true, errorType: 'else' }); + + expect( + screen.queryByRole('button', { name: COPY_LINK_LABEL }), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/page/TimerPage/components/LiveShareModal.tsx b/src/page/TimerPage/components/LiveShareModal.tsx index af2480c3..6896765d 100644 --- a/src/page/TimerPage/components/LiveShareModal.tsx +++ b/src/page/TimerPage/components/LiveShareModal.tsx @@ -1,8 +1,15 @@ +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { QRCodeSVG } from 'qrcode.react'; +import { IoLinkOutline } from 'react-icons/io5'; import LoadingSpinner from '../../../components/LoadingSpinner'; import DTClose from '../../../components/icons/Close'; import { LiveShareErrorType } from '../hooks/useLiveShare'; +import { copyToClipboard } from '../../../util/clipboard'; + +const COPY_FEEDBACK_DURATION_MS = 2000; + +type CopyStatus = 'idle' | 'copied' | 'failed'; interface LiveShareModalProps { shareUrl: string; @@ -23,6 +30,29 @@ export default function LiveShareModal({ onRestart, }: LiveShareModalProps) { const { t } = useTranslation(); + const [copyStatus, setCopyStatus] = useState('idle'); + const copyFeedbackTimerRef = useRef>(); + + useEffect(() => { + return () => clearTimeout(copyFeedbackTimerRef.current); + }, []); + + const handleCopyLink = async () => { + const isCopied = await copyToClipboard(shareUrl); + setCopyStatus(isCopied ? 'copied' : 'failed'); + + clearTimeout(copyFeedbackTimerRef.current); + copyFeedbackTimerRef.current = setTimeout(() => { + setCopyStatus('idle'); + }, COPY_FEEDBACK_DURATION_MS); + }; + + const copyButtonLabel = { + idle: t('링크 공유'), + copied: t('링크 복사됨'), + failed: t('링크 복사 실패'), + }[copyStatus]; + const isReplaced = errorType === 'replaced'; const isDisconnected = errorType === 'disconnected'; let errorTitle = t('라이브 공유 불가'); @@ -41,7 +71,7 @@ export default function LiveShareModal({ }[errorType]; return ( -
+
{isLoading ? (
+ + ) : ( <> diff --git a/src/util/clipboard.test.ts b/src/util/clipboard.test.ts new file mode 100644 index 00000000..fd4b122d --- /dev/null +++ b/src/util/clipboard.test.ts @@ -0,0 +1,63 @@ +import { vi } from 'vitest'; +import { copyToClipboard } from './clipboard'; + +const TEXT = 'https://example.com/live/1'; + +function mockClipboard(writeText?: (text: string) => Promise) { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: writeText ? { writeText } : undefined, + }); +} + +function mockExecCommand(result: boolean) { + const execCommand = vi.fn().mockReturnValue(result); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + return execCommand; +} + +describe('copyToClipboard', () => { + afterEach(() => { + mockClipboard(); + }); + + test('Clipboard API가 있으면 writeText로 복사한다', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + mockClipboard(writeText); + const execCommand = mockExecCommand(true); + + await expect(copyToClipboard(TEXT)).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith(TEXT); + expect(execCommand).not.toHaveBeenCalled(); + }); + + test('Clipboard API가 없으면 execCommand로 폴백한다', async () => { + mockClipboard(); + const execCommand = mockExecCommand(true); + + await expect(copyToClipboard(TEXT)).resolves.toBe(true); + + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(document.querySelector('textarea')).not.toBeInTheDocument(); + }); + + test('writeText가 실패하면 execCommand로 폴백한다', async () => { + mockClipboard(vi.fn().mockRejectedValue(new Error('denied'))); + const execCommand = mockExecCommand(true); + + await expect(copyToClipboard(TEXT)).resolves.toBe(true); + + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + + test('모든 복사 방법이 실패하면 false를 반환한다', async () => { + mockClipboard(vi.fn().mockRejectedValue(new Error('denied'))); + mockExecCommand(false); + + await expect(copyToClipboard(TEXT)).resolves.toBe(false); + }); +}); diff --git a/src/util/clipboard.ts b/src/util/clipboard.ts new file mode 100644 index 00000000..5f780e91 --- /dev/null +++ b/src/util/clipboard.ts @@ -0,0 +1,35 @@ +/** + * 텍스트를 클립보드에 복사하고 성공 여부를 반환합니다. + * Clipboard API는 보안 컨텍스트(HTTPS, localhost)에서만 동작하므로, + * 지원하지 않거나 실패하면 execCommand('copy')로 폴백합니다. + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 권한 거부 등으로 실패하면 아래 폴백을 시도합니다. + } + } + + return copyWithExecCommand(text); +} + +function copyWithExecCommand(text: string): boolean { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } +} From 59125feb953e2b131b71ca173d04549f4cab56e7 Mon Sep 17 00:00:00 2001 From: coli Date: Sat, 26 Sep 2026 00:25:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[FIX]=20=EB=A7=81=ED=81=AC=20=EC=97=B0?= =?UTF-8?q?=EC=86=8D=20=EB=B3=B5=EC=82=AC=20=EC=8B=9C=20=EB=8A=A6=EA=B2=8C?= =?UTF-8?q?=20=EB=81=9D=EB=82=9C=20=EC=9D=B4=EC=A0=84=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EA=B0=80=20=EC=B5=9C=EC=8B=A0=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EB=8D=AE=EC=96=B4=EC=93=B0=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../components/LiveShareModal.test.tsx | 34 +++++++++++++++++++ .../TimerPage/components/LiveShareModal.tsx | 9 ++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/page/TimerPage/components/LiveShareModal.test.tsx b/src/page/TimerPage/components/LiveShareModal.test.tsx index b1e8e19b..d7239932 100644 --- a/src/page/TimerPage/components/LiveShareModal.test.tsx +++ b/src/page/TimerPage/components/LiveShareModal.test.tsx @@ -204,6 +204,40 @@ describe('LiveShareModal', () => { ).toBeInTheDocument(); }); + test('먼저 누른 복사가 늦게 끝나도 마지막 복사 결과를 보여준다', async () => { + const user = userEvent.setup(); + let rejectFirstCopy: (reason: Error) => void = () => {}; + vi.spyOn(navigator.clipboard, 'writeText') + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectFirstCopy = reject; + }), + ) + .mockResolvedValueOnce(undefined); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(false), + }); + + renderLiveShareModal(); + + const copyButton = screen.getByRole('button', { name: COPY_LINK_LABEL }); + await user.click(copyButton); + await user.click(copyButton); + expect( + screen.getByRole('button', { name: COPIED_LABEL }), + ).toBeInTheDocument(); + + await act(async () => { + rejectFirstCopy(new Error('denied')); + }); + + expect( + screen.getByRole('button', { name: COPIED_LABEL }), + ).toBeInTheDocument(); + }); + test('오류 상태에서는 링크 공유 버튼을 보여주지 않는다', () => { renderLiveShareModal({ isError: true, errorType: 'else' }); diff --git a/src/page/TimerPage/components/LiveShareModal.tsx b/src/page/TimerPage/components/LiveShareModal.tsx index 6896765d..5a64f59f 100644 --- a/src/page/TimerPage/components/LiveShareModal.tsx +++ b/src/page/TimerPage/components/LiveShareModal.tsx @@ -32,16 +32,23 @@ export default function LiveShareModal({ const { t } = useTranslation(); const [copyStatus, setCopyStatus] = useState('idle'); const copyFeedbackTimerRef = useRef>(); + const copyRequestIdRef = useRef(0); useEffect(() => { return () => clearTimeout(copyFeedbackTimerRef.current); }, []); const handleCopyLink = async () => { + // 연속 클릭 시 늦게 끝난 이전 복사 결과가 최신 결과를 덮어쓰지 않도록 합니다. + const requestId = ++copyRequestIdRef.current; + clearTimeout(copyFeedbackTimerRef.current); + const isCopied = await copyToClipboard(shareUrl); + if (requestId !== copyRequestIdRef.current) { + return; + } setCopyStatus(isCopied ? 'copied' : 'failed'); - clearTimeout(copyFeedbackTimerRef.current); copyFeedbackTimerRef.current = setTimeout(() => { setCopyStatus('idle'); }, COPY_FEEDBACK_DURATION_MS);