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
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ function ReceiptShareDialog({
const captureLayerRef = useRef<HTMLDivElement | null>(null);
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
/** state 는 버튼 비활성화용, ref 는 즉시 차단용 */
const [isSharingLink, setIsSharingLink] = useState(false);
const isSharingLinkRef = useRef(false);
const { shareToStory, isSharing } = useInstagramStoryShare();

/** 스토리 공유는 네이티브 전용 — SSR 은 false 로 두어 hydration mismatch 를 피한다 */
Expand Down Expand Up @@ -159,6 +162,9 @@ function ReceiptShareDialog({
/** WebBridge 가 이미 업데이트 안내를 띄웠다 */
if (status === 'blocked') return;

/** 연타로 인한 중복 호출 — 안내 없이 무시 */
if (status === 'busy') return;

if (status === 'notInstalled') {
toast.warning('인스타그램 앱을 설치하면 스토리에 공유할 수 있어요.');
return;
Expand All @@ -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;
Expand Down Expand Up @@ -246,7 +263,7 @@ function ReceiptShareDialog({
<ShareAction
icon={<LinkIconOutline className="size-6 text-text-neutral-secondary" />}
label="링크 공유"
disabled={!imageBlob}
disabled={!imageBlob || isSharingLink}
onClick={handleShareLink}
/>
{isAppEnvironment && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,21 @@ export const copyReceiptImage = async (blob: Blob): Promise<boolean> => {
}
};

/**
* 공유 시트가 이미 떠 있는데 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] })) {
Expand All @@ -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';
}
};
9 changes: 8 additions & 1 deletion apps/web/src/hooks/useInstagramStoryShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +27,7 @@ type PendingRequestT = {
export const useInstagramStoryShare = () => {
const pendingRequestsRef = useRef<Map<string, PendingRequestT>>(new Map());
const [isSharing, setIsSharing] = useState(false);
const isSharingRef = useRef(false);

const settleRequest = useCallback((requestId: string, status: ShareInstagramStoryStatusT) => {
const pending = pendingRequestsRef.current.get(requestId);
Expand Down Expand Up @@ -63,8 +65,12 @@ export const useInstagramStoryShare = () => {
);

const shareToStory = useCallback(async (imageBlob: Blob): Promise<InstagramStoryShareResultT> => {
/** 연타 방지 — state 는 리렌더 후에야 반영돼 그 사이 클릭을 막지 못한다 */
if (isSharingRef.current) return 'busy';

try {
const requestId = crypto.randomUUID();
isSharingRef.current = true;
setIsSharing(true);

const base64 = await blobToBase64(imageBlob);
Expand Down Expand Up @@ -95,6 +101,7 @@ export const useInstagramStoryShare = () => {
} catch {
return 'error';
} finally {
isSharingRef.current = false;
setIsSharing(false);
}
}, []);
Expand Down
Loading