Skip to content

Commit bff21aa

Browse files
yuchem2claude
andauthored
feat(meeting): 회의 헤더 제목으로 초대 링크 복사 (#34)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 9d0d328 commit bff21aa

5 files changed

Lines changed: 173 additions & 8 deletions

File tree

‎apps/frontend/src/feature/meeting/components/MeetingScreen.spec.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, screen } from '@testing-library/react';
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
22

33
import type {
44
RemoteMediaEntry,
@@ -119,6 +119,24 @@ describe('MeetingScreen View', () => {
119119
expect(screen.getByRole('status')).toHaveTextContent('연결 중');
120120
});
121121

122+
it('헤더 제목은 회의 링크를 복사하는 버튼이다', () => {
123+
renderScreen();
124+
expect(screen.getByRole('button', { name: /회의 링크 복사/ })).toBeInTheDocument();
125+
});
126+
127+
it('제목을 누르면 회의 링크가 클립보드에 들어가고 복사됨을 알린다', async () => {
128+
const writeText = vi.fn().mockResolvedValue(undefined);
129+
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
130+
renderScreen();
131+
132+
fireEvent.click(screen.getByRole('button', { name: /회의 링크 복사/ }));
133+
134+
await waitFor(() =>
135+
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/meetings/abc12xyz')),
136+
);
137+
expect(await screen.findByText('링크 복사됨')).toBeInTheDocument();
138+
});
139+
122140
it('status="error" + errorMessage가 있으면 alert로 노출', () => {
123141
renderScreen({ status: 'error', errorMessage: 'handshake 실패' });
124142
expect(screen.getByRole('alert')).toHaveTextContent('handshake 실패');

‎apps/frontend/src/feature/meeting/components/MeetingScreen.tsx‎

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
'use client';
22

3+
import { LinkIcon } from '@/feature/meeting/components/icons';
34
import { MeetingControlBar } from '@/feature/meeting/components/MeetingControlBar';
45
import { RemoteAudioPlayer } from '@/feature/meeting/components/MeetingMedia';
56
import { VideoStage } from '@/feature/meeting/components/VideoStage';
67
import type { UseMediasoupViewModel } from '@/feature/meeting/hooks/useMediasoupViewModel';
78
import { useMeetingElapsedViewModel } from '@/feature/meeting/hooks/useMeetingElapsedViewModel';
89
import type { MeetingLayoutVariant } from '@/feature/meeting/hooks/useMeetingLayoutViewModel';
10+
import {
11+
type CopyLinkStatus,
12+
useMeetingLinkViewModel,
13+
} from '@/feature/meeting/hooks/useMeetingLinkViewModel';
914
import type {
1015
MeetingConnectionStatus,
1116
UseMeetingViewModel,
@@ -22,6 +27,46 @@ const STATUS_TONE_DEFAULT = { text: 'text-muted', dot: 'bg-pending' } as const;
2227
const statusLabel = (status: MeetingConnectionStatus): string =>
2328
status === 'joined' ? '연결됨' : status === 'reconnecting' ? '재접속 중…' : status;
2429

30+
const COPY_FEEDBACK: Record<Exclude<CopyLinkStatus, 'idle'>, string> = {
31+
copied: '링크 복사됨',
32+
error: '복사하지 못했습니다',
33+
};
34+
35+
function MeetingTitle({ code, title }: { readonly code: string; readonly title: string | null }) {
36+
const { url, status, copy } = useMeetingLinkViewModel(code);
37+
const label = title ?? `회의 ${code}`;
38+
return (
39+
<h1 className="text-title flex min-w-0 items-center gap-2.5 font-bold tracking-[-0.025em]">
40+
<button
41+
type="button"
42+
onClick={copy}
43+
title={url}
44+
aria-label={`${label} — 회의 링크 복사`}
45+
className="text-text hover:text-accent-on group flex min-w-0 items-center gap-2 transition-colors"
46+
>
47+
<span className="truncate">
48+
{title ?? (
49+
<>
50+
회의 <span className="text-code font-mono font-medium">{code}</span>
51+
</>
52+
)}
53+
</span>
54+
<LinkIcon className="text-muted group-hover:text-accent-on h-4 w-4 shrink-0 transition-colors" />
55+
</button>
56+
{status !== 'idle' && (
57+
<span
58+
role="status"
59+
className={`text-meta shrink-0 font-semibold ${
60+
status === 'copied' ? 'text-positive' : 'text-danger-on'
61+
}`}
62+
>
63+
{COPY_FEEDBACK[status]}
64+
</span>
65+
)}
66+
</h1>
67+
);
68+
}
69+
2570
/**
2671
* 경과 시간은 1초마다 바뀐다. 헤더에 인라인으로 두면 그 틱이 회의 화면 전체를 —
2772
* 참가자 수만큼 깔린 비디오 타일까지 — 다시 그린다. 상태를 이 경계 안에 가둔다.
@@ -92,13 +137,10 @@ export function MeetingScreen({
92137
<section className="flex h-full min-w-0 flex-1 flex-col">
93138
<header className="border-border px-gutter py-gutter-sm flex items-center justify-between gap-3 border-b">
94139
<div className="min-w-0">
95-
<h1 className="text-text text-title truncate font-bold tracking-[-0.025em]">
96-
{title ?? (
97-
<>
98-
회의 <span className="text-code font-mono font-medium">{code}</span>
99-
</>
100-
)}
101-
</h1>
140+
<MeetingTitle
141+
code={code}
142+
title={title}
143+
/>
102144
<p className="text-muted text-meta mt-1 truncate font-medium">
103145
{title !== null && <span className="font-mono">{code} · </span>}내 닉네임:{' '}
104146
{nickname ?? '(미인증)'} · 참가자 {participantCount}명

‎apps/frontend/src/feature/meeting/components/icons.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,12 @@ export function EndCallIcon({ className }: IconProps) {
104104
</svg>
105105
);
106106
}
107+
108+
export function LinkIcon({ className }: IconProps) {
109+
return (
110+
<svg {...base(className)}>
111+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
112+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
113+
</svg>
114+
);
115+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { act, renderHook, waitFor } from '@testing-library/react';
2+
3+
import { useMeetingLinkViewModel } from './useMeetingLinkViewModel';
4+
5+
const writeText = vi.fn();
6+
7+
const linkOf = (code: string): string => `${window.location.origin}/meetings/${code}`;
8+
9+
describe('useMeetingLinkViewModel', () => {
10+
beforeEach(() => {
11+
writeText.mockReset();
12+
writeText.mockResolvedValue(undefined);
13+
Object.defineProperty(navigator, 'clipboard', {
14+
value: { writeText },
15+
configurable: true,
16+
});
17+
});
18+
19+
it('회의 링크는 현재 origin 기준의 회의 주소다', async () => {
20+
const { result } = renderHook(() => useMeetingLinkViewModel('abc12xyz'));
21+
await waitFor(() => expect(result.current.url).toBe(linkOf('abc12xyz')));
22+
});
23+
24+
it('복사하면 회의 링크를 클립보드에 쓰고 copied가 된다', async () => {
25+
const { result } = renderHook(() => useMeetingLinkViewModel('abc12xyz'));
26+
await act(async () => {
27+
result.current.copy();
28+
});
29+
expect(writeText).toHaveBeenCalledWith(linkOf('abc12xyz'));
30+
expect(result.current.status).toBe('copied');
31+
});
32+
33+
it('복사 피드백은 잠시 뒤 사라진다', async () => {
34+
vi.useFakeTimers({ shouldAdvanceTime: true });
35+
try {
36+
const { result } = renderHook(() => useMeetingLinkViewModel('abc12xyz'));
37+
await act(async () => {
38+
result.current.copy();
39+
});
40+
expect(result.current.status).toBe('copied');
41+
42+
await act(async () => {
43+
vi.advanceTimersByTime(3000);
44+
});
45+
expect(result.current.status).toBe('idle');
46+
} finally {
47+
vi.useRealTimers();
48+
}
49+
});
50+
51+
it('클립보드를 쓸 수 없으면 error로 알린다', async () => {
52+
writeText.mockRejectedValueOnce(new Error('permission denied'));
53+
const { result } = renderHook(() => useMeetingLinkViewModel('abc12xyz'));
54+
await act(async () => {
55+
result.current.copy();
56+
});
57+
expect(result.current.status).toBe('error');
58+
});
59+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useState } from 'react';
4+
5+
export type CopyLinkStatus = 'idle' | 'copied' | 'error';
6+
7+
export interface UseMeetingLinkViewModel {
8+
readonly url: string;
9+
readonly status: CopyLinkStatus;
10+
readonly copy: () => void;
11+
}
12+
13+
const FEEDBACK_MS = 1_800;
14+
15+
export function useMeetingLinkViewModel(code: string): UseMeetingLinkViewModel {
16+
const [url, setUrl] = useState('');
17+
const [status, setStatus] = useState<CopyLinkStatus>('idle');
18+
19+
useEffect(() => {
20+
setUrl(`${window.location.origin}/meetings/${code}`);
21+
}, [code]);
22+
23+
useEffect(() => {
24+
if (status === 'idle') return;
25+
const timer = window.setTimeout(() => setStatus('idle'), FEEDBACK_MS);
26+
return () => window.clearTimeout(timer);
27+
}, [status]);
28+
29+
const copy = useCallback(() => {
30+
void navigator.clipboard
31+
.writeText(`${window.location.origin}/meetings/${code}`)
32+
.then(() => setStatus('copied'))
33+
.catch(() => setStatus('error'));
34+
}, [code]);
35+
36+
return { url, status, copy };
37+
}

0 commit comments

Comments
 (0)