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..d7239932 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,106 @@ 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('먼저 누른 복사가 늦게 끝나도 마지막 복사 결과를 보여준다', 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' }); + + 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..5a64f59f 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,36 @@ export default function LiveShareModal({ onRestart, }: LiveShareModalProps) { 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'); + + 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 +78,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); + } +}