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
5 changes: 4 additions & 1 deletion public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 4 additions & 1 deletion public/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,5 +316,8 @@
"라이브 연결 끊김": "라이브 연결 끊김",
"라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.",
"필요한 데이터를 불러오지 못했어요.": "필요한 데이터를 불러오지 못했어요.",
"다시 시도해보세요.": "다시 시도해보세요."
"다시 시도해보세요.": "다시 시도해보세요.",
"링크 공유": "링크 공유",
"링크 복사됨": "링크 복사됨",
"링크 복사 실패": "링크 복사 실패"
}
107 changes: 106 additions & 1 deletion src/page/TimerPage/components/LiveShareModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 코드를 스캔하면 토론 타이머 화면이 자동으로 열립니다.';

Expand Down Expand Up @@ -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<void>((_, 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();
});
});
49 changes: 48 additions & 1 deletion src/page/TimerPage/components/LiveShareModal.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,6 +30,36 @@ export default function LiveShareModal({
onRestart,
}: LiveShareModalProps) {
const { t } = useTranslation();
const [copyStatus, setCopyStatus] = useState<CopyStatus>('idle');
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout>>();
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(() => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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('라이브 공유 불가');
Expand All @@ -41,7 +78,7 @@ export default function LiveShareModal({
}[errorType];

return (
<div className="flex h-[250px] w-[300px] flex-col items-center justify-between rounded-2xl border-2 border-default-disabled/hover p-6">
<div className="flex h-[300px] w-[300px] flex-col items-center justify-between rounded-2xl border-2 border-default-disabled/hover p-6">
{isLoading ? (
<div className="flex size-full items-center justify-center">
<LoadingSpinner
Expand Down Expand Up @@ -85,6 +122,16 @@ export default function LiveShareModal({
)}
</p>
</div>

<button
type="button"
onClick={handleCopyLink}
aria-live="polite"
className="flex w-full items-center justify-center gap-2 rounded-full bg-brand px-4 py-2 text-[14px] font-semibold text-default-black transition-colors hover:bg-brand-hover"
>
<IoLinkOutline className="size-[18px]" aria-hidden />
{copyButtonLabel}
</button>
</>
) : (
<>
Expand Down
63 changes: 63 additions & 0 deletions src/util/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
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);
});
});
35 changes: 35 additions & 0 deletions src/util/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* 텍스트를 클립보드에 복사하고 성공 여부를 반환합니다.
* Clipboard API는 보안 컨텍스트(HTTPS, localhost)에서만 동작하므로,
* 지원하지 않거나 실패하면 execCommand('copy')로 폴백합니다.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
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);
}
}
Loading