diff --git a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx index a114cc33..58fb2b3f 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx @@ -78,6 +78,9 @@ function ReceiptShareDialog({ const captureLayerRef = useRef(null); const [imageBlob, setImageBlob] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); + /** state 는 버튼 비활성화용, ref 는 즉시 차단용 */ + const [isSharingLink, setIsSharingLink] = useState(false); + const isSharingLinkRef = useRef(false); const { shareToStory, isSharing } = useInstagramStoryShare(); /** 스토리 공유는 네이티브 전용 — SSR 은 false 로 두어 hydration mismatch 를 피한다 */ @@ -159,6 +162,9 @@ function ReceiptShareDialog({ /** WebBridge 가 이미 업데이트 안내를 띄웠다 */ if (status === 'blocked') return; + /** 연타로 인한 중복 호출 — 안내 없이 무시 */ + if (status === 'busy') return; + if (status === 'notInstalled') { toast.warning('인스타그램 앱을 설치하면 스토리에 공유할 수 있어요.'); return; @@ -178,8 +184,19 @@ function ReceiptShareDialog({ const handleShareLink = async () => { if (!imageBlob) return; + /** 연타 방지 — state 는 리렌더 후에야 반영돼 그 사이 클릭을 막지 못한다 */ + if (isSharingLinkRef.current) return; + + isSharingLinkRef.current = true; + setIsSharingLink(true); - const shareResult = await shareReceiptImageFile(imageBlob); + const shareResult = await shareReceiptImageFile(imageBlob).finally(() => { + isSharingLinkRef.current = false; + setIsSharingLink(false); + }); + + /** 이미 열린 공유 시트에서 발생한 중복 호출 — 사용자 입장에선 정상이므로 조용히 무시한다 */ + if (shareResult === 'busy') return; if (shareResult === 'unsupported') { toast.warning('이 환경에서는 공유를 지원하지 않아요. 저장을 이용해주세요.'); return; @@ -246,7 +263,7 @@ function ReceiptShareDialog({ } label="링크 공유" - disabled={!imageBlob} + disabled={!imageBlob || isSharingLink} onClick={handleShareLink} /> {isAppEnvironment && ( diff --git a/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts b/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts index 49bb2fce..07f237d7 100644 --- a/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts +++ b/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts @@ -111,14 +111,21 @@ export const copyReceiptImage = async (blob: Blob): Promise => { } }; +/** + * 공유 시트가 이미 떠 있는데 navigator.share() 를 다시 부르면 나는 에러. + * 브라우저마다 이름이 갈린다 — Chrome 계열은 InvalidStateError, Safari 는 NotAllowedError. + */ +const CONCURRENT_SHARE_ERROR_NAMES = ['InvalidStateError', 'NotAllowedError']; + /** * 캡처된 blob 을 시스템 공유 시트로 전달 (카톡 등 앱 선택은 사용자 몫). * - * @returns 'shared' 공유 완료 · 'cancelled' 사용자가 시트를 닫음 · 'unsupported' 파일 공유 미지원 + * @returns 'shared' 공유 완료 · 'cancelled' 사용자가 시트를 닫음 · + * 'busy' 이미 공유 진행 중 · 'unsupported' 파일 공유 미지원 */ export const shareReceiptImageFile = async ( blob: Blob -): Promise<'shared' | 'cancelled' | 'unsupported'> => { +): Promise<'shared' | 'cancelled' | 'busy' | 'unsupported'> => { const file = new File([blob], FILE_NAME, { type: MIME }); if (typeof navigator?.canShare !== 'function' || !navigator.canShare({ files: [file] })) { @@ -129,7 +136,10 @@ export const shareReceiptImageFile = async ( await navigator.share({ files: [file] }); return 'shared'; } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return 'cancelled'; + if (!(error instanceof DOMException)) return 'unsupported'; + if (error.name === 'AbortError') return 'cancelled'; + // 연타로 인한 중복 호출까지 미지원으로 뭉뚱그리면 엉뚱한 안내가 나간다. + if (CONCURRENT_SHARE_ERROR_NAMES.includes(error.name)) return 'busy'; return 'unsupported'; } }; diff --git a/apps/web/src/hooks/useInstagramStoryShare.ts b/apps/web/src/hooks/useInstagramStoryShare.ts index 9d34c6cd..604408b2 100644 --- a/apps/web/src/hooks/useInstagramStoryShare.ts +++ b/apps/web/src/hooks/useInstagramStoryShare.ts @@ -11,7 +11,8 @@ import { WebBridge } from '@/utils/webBridge'; const RESPONSE_TIMEOUT_MS = 15_000; /** `blocked` 는 앱 버전 게이트에 막혀 전송조차 안 된 경우 */ -export type InstagramStoryShareResultT = ShareInstagramStoryStatusT | 'blocked'; +/** 'blocked' 앱 버전 게이트에 막힘 · 'busy' 이미 공유 진행 중 */ +export type InstagramStoryShareResultT = ShareInstagramStoryStatusT | 'blocked' | 'busy'; type PendingRequestT = { resolve: (status: ShareInstagramStoryStatusT) => void; @@ -26,6 +27,7 @@ type PendingRequestT = { export const useInstagramStoryShare = () => { const pendingRequestsRef = useRef>(new Map()); const [isSharing, setIsSharing] = useState(false); + const isSharingRef = useRef(false); const settleRequest = useCallback((requestId: string, status: ShareInstagramStoryStatusT) => { const pending = pendingRequestsRef.current.get(requestId); @@ -63,8 +65,12 @@ export const useInstagramStoryShare = () => { ); const shareToStory = useCallback(async (imageBlob: Blob): Promise => { + /** 연타 방지 — state 는 리렌더 후에야 반영돼 그 사이 클릭을 막지 못한다 */ + if (isSharingRef.current) return 'busy'; + try { const requestId = crypto.randomUUID(); + isSharingRef.current = true; setIsSharing(true); const base64 = await blobToBase64(imageBlob); @@ -95,6 +101,7 @@ export const useInstagramStoryShare = () => { } catch { return 'error'; } finally { + isSharingRef.current = false; setIsSharing(false); } }, []);