diff --git a/apps/app/app.json b/apps/app/app.json index 456114a8..52964cb1 100644 --- a/apps/app/app.json +++ b/apps/app/app.json @@ -188,6 +188,12 @@ "nativeAppKey": "326734abdf8de5c7e090a2fc72e1dce6" } ], + [ + "./plugins/withInstagramStoryShare.js", + { + "facebookAppId": "2060093567931900" + } + ], [ "@sentry/react-native", { diff --git a/apps/app/modules/instagram-story/expo-module.config.json b/apps/app/modules/instagram-story/expo-module.config.json new file mode 100644 index 00000000..59de73b7 --- /dev/null +++ b/apps/app/modules/instagram-story/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "ios": { + "modules": ["InstagramStoryModule"] + } +} diff --git a/apps/app/modules/instagram-story/ios/InstagramStory.podspec b/apps/app/modules/instagram-story/ios/InstagramStory.podspec new file mode 100644 index 00000000..eba40e7a --- /dev/null +++ b/apps/app/modules/instagram-story/ios/InstagramStory.podspec @@ -0,0 +1,20 @@ +Pod::Spec.new do |s| + s.name = 'InstagramStory' + s.version = '1.0.0' + s.summary = 'iOS 인스타그램 스토리 공유 (전용 pasteboard 키)' + s.description = '인스타그램이 요구하는 com.instagram.sharedSticker.* pasteboard 키로 이미지를 전달한다.' + s.author = '' + s.homepage = 'https://github.com/TeamPiKi/client' + s.platforms = { :ios => '15.1' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/app/modules/instagram-story/ios/InstagramStoryModule.swift b/apps/app/modules/instagram-story/ios/InstagramStoryModule.swift new file mode 100644 index 00000000..61db82d3 --- /dev/null +++ b/apps/app/modules/instagram-story/ios/InstagramStoryModule.swift @@ -0,0 +1,46 @@ +import ExpoModulesCore +import UIKit + +/** + * 인스타그램 스토리 공유 (iOS). + * + * 인스타그램은 일반 이미지 붙여넣기가 아니라 `com.instagram.sharedSticker.*` 전용 + * pasteboard 키를 읽는다. expo-clipboard 로는 이 키를 지정할 수 없어 직접 구현한다. + * https://developers.facebook.com/docs/instagram-platform/sharing-to-stories/ + */ +public class InstagramStoryModule: Module { + private static let backgroundImageKey = "com.instagram.sharedSticker.backgroundImage" + private static let scheme = "instagram-stories://share" + /** 붙여넣기 데이터가 남지 않도록 짧게 만료시킨다 */ + private static let pasteboardExpirySeconds: TimeInterval = 60 * 5 + + public func definition() -> ModuleDefinition { + Name("InstagramStory") + + AsyncFunction("shareBackgroundImage") { (base64: String, facebookAppId: String) -> String in + guard let imageData = Data(base64Encoded: base64) else { + return "error" + } + + // App ID 를 붙이지 않으면 인스타그램이 미지원 안내를 띄운다 (2023-01 이후 필수) + guard let url = URL(string: "\(Self.scheme)?source_application=\(facebookAppId)") else { + return "error" + } + + return await MainActor.run { + guard UIApplication.shared.canOpenURL(url) else { + return "notInstalled" + } + + UIPasteboard.general.setItems( + [[Self.backgroundImageKey: imageData]], + options: [.expirationDate: Date().addingTimeInterval(Self.pasteboardExpirySeconds)] + ) + + UIApplication.shared.open(url, options: [:], completionHandler: nil) + + return "success" + } + } + } +} diff --git a/apps/app/modules/instagram-story/package.json b/apps/app/modules/instagram-story/package.json new file mode 100644 index 00000000..d22b5273 --- /dev/null +++ b/apps/app/modules/instagram-story/package.json @@ -0,0 +1,7 @@ +{ + "name": "instagram-story", + "version": "1.0.0", + "description": "iOS 인스타그램 스토리 공유 (전용 pasteboard 키)", + "main": "src/index.ts", + "private": true +} diff --git a/apps/app/modules/instagram-story/src/index.ts b/apps/app/modules/instagram-story/src/index.ts new file mode 100644 index 00000000..4f52fa90 --- /dev/null +++ b/apps/app/modules/instagram-story/src/index.ts @@ -0,0 +1,45 @@ +import type { ShareInstagramStoryStatusT } from '@piki/core'; +import Constants from 'expo-constants'; +import { requireOptionalNativeModule } from 'expo-modules-core'; + +const PLUGIN_PATH = './plugins/withInstagramStoryShare.js'; + +/** + * app.json 에 등록한 withInstagramStoryShare 플러그인의 facebookAppId 를 읽는다. + * Info.plist 와 딥링크가 같은 값을 써야 해서 단일 출처로 둔다. + */ +export const getFacebookAppId = (): string => { + const plugins = Constants.expoConfig?.plugins ?? []; + + const entry = plugins.find( + (plugin): plugin is [string, { facebookAppId?: string }] => + Array.isArray(plugin) && plugin[0] === PLUGIN_PATH + ); + + return entry?.[1]?.facebookAppId ?? ''; +}; + +type InstagramStoryModuleT = { + shareBackgroundImage: ( + base64: string, + facebookAppId: string + ) => Promise; +}; + +/** iOS 전용 모듈 — 안드로이드/미포함 빌드에서는 null */ +const InstagramStoryModule = requireOptionalNativeModule('InstagramStory'); + +export const isInstagramStoryModuleAvailable = InstagramStoryModule !== null; + +/** + * 인스타그램 스토리 편집 화면으로 배경 이미지를 전달한다 (iOS). + * 모듈이 없거나 App ID 가 비어 있으면 'error' 를 돌려준다. + */ +export const shareInstagramStoryBackground = async ( + base64: string, + facebookAppId: string +): Promise => { + if (!InstagramStoryModule || !facebookAppId) return 'error'; + + return InstagramStoryModule.shareBackgroundImage(base64, facebookAppId); +}; diff --git a/apps/app/package.json b/apps/app/package.json index ddec6c43..05532e23 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -35,6 +35,7 @@ "expo-image-picker": "^17.0.11", "expo-intent-launcher": "~13.0.8", "expo-linking": "~8.0.11", + "expo-modules-core": "3.0.29", "expo-notifications": "^0.32.17", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", diff --git a/apps/app/plugins/withInstagramStoryShare.js b/apps/app/plugins/withInstagramStoryShare.js new file mode 100644 index 00000000..fa557aed --- /dev/null +++ b/apps/app/plugins/withInstagramStoryShare.js @@ -0,0 +1,26 @@ +const { withInfoPlist } = require('@expo/config-plugins'); + +/** + * iOS 인스타그램 스토리 공유에 필요한 Info.plist 설정. + * + * 2023-01 부터 인스타그램이 스토리 공유에 Facebook App ID 를 요구한다. + * 없으면 인스타그램이 "이 앱은 스토리 공유를 지원하지 않는다" 안내만 띄운다. + * https://developers.facebook.com/docs/instagram-platform/sharing-to-stories/ + * + * `instagram-stories` 스킴은 app.json 의 LSApplicationQueriesSchemes 에 이미 있다. + */ +const withInstagramStoryShare = (config, { facebookAppId } = {}) => { + if (!facebookAppId) { + // 값이 없으면 조용히 건너뛴다 — 빌드는 되고 스토리 공유만 동작하지 않는다. + // (환경변수 미설정인 CI/로컬에서 빌드 자체가 깨지지 않도록) + return config; + } + + return withInfoPlist(config, config => { + config.modResults.FacebookAppID = facebookAppId; + + return config; + }); +}; + +module.exports = withInstagramStoryShare; diff --git a/apps/app/utils/handleInstagramStory.ts b/apps/app/utils/handleInstagramStory.ts index 9be8edf1..05cce1ad 100644 --- a/apps/app/utils/handleInstagramStory.ts +++ b/apps/app/utils/handleInstagramStory.ts @@ -3,15 +3,21 @@ import { type ShareInstagramStoryStatusT, WEBBRIDGE_MESSAGE_TYPE, } from '@piki/core'; -import { setImageAsync } from 'expo-clipboard'; import { Directory, File, Paths } from 'expo-file-system'; /** content:// URI 변환은 아직 legacy 엔트리에만 있다 (루트 export 는 런타임에 throw) */ import { getContentUriAsync } from 'expo-file-system/legacy'; import { startActivityAsync } from 'expo-intent-launcher'; import { Linking, Platform } from 'react-native'; +import { getFacebookAppId, shareInstagramStoryBackground } from '@/modules/instagram-story/src'; import { WebBridge } from '@/utils/webBridge'; +/** + * 인스타그램이 스토리 공유 요청자를 식별하는 값 — 없으면 미지원 안내가 뜬다. + * app.json 의 withInstagramStoryShare 플러그인 설정을 그대로 읽어 값 중복을 피한다. + */ +const FACEBOOK_APP_ID = getFacebookAppId(); + /** 이 스킴이 열리면 인스타그램이 설치돼 있다는 뜻 */ const INSTAGRAM_STORIES_SCHEME = 'instagram-stories://share'; const ANDROID_ADD_TO_STORY_ACTION = 'com.instagram.share.ADD_TO_STORY'; @@ -33,16 +39,13 @@ const writeStoryImageFile = (base64: string) => { return file; }; -/** iOS — 인스타그램이 pasteboard 에서 이미지를 읽어가므로 딥링크보다 먼저 복사해야 한다 */ -const shareToStoryOnIos = async (base64: string): Promise => { - const canOpen = await Linking.canOpenURL(INSTAGRAM_STORIES_SCHEME); - if (!canOpen) return 'notInstalled'; - - await setImageAsync(base64); - await Linking.openURL(INSTAGRAM_STORIES_SCHEME); - - return 'success'; -}; +/** + * iOS — 전용 pasteboard 키(com.instagram.sharedSticker.*)로 넘겨야 인스타그램이 읽는다. + * 일반 이미지 복사로는 "이 앱은 스토리 공유를 지원하지 않는다" 안내만 뜬다. + * Facebook App ID 도 2023-01 부터 필수라 네이티브 모듈에서 함께 처리한다. + */ +const shareToStoryOnIos = async (base64: string): Promise => + shareInstagramStoryBackground(base64, FACEBOOK_APP_ID); /** Android — FileProvider 로 노출한 content:// URI 여야 인스타그램이 읽을 수 있다 */ const shareToStoryOnAndroid = async ( diff --git a/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx b/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx index 2bd840c5..c60da6fe 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx @@ -23,9 +23,11 @@ import ReceiptShareDialog from './receipt-share-dialog/ReceiptShareDialog'; type ResultClientProps = { tournamentId: number; isGuest?: boolean; + /** 서버가 UA 로 판정한 앱 여부 — hydration 전에도 앱 전용 UI 를 그리기 위해 받는다 */ + isApp?: boolean; }; -function ResultClient({ tournamentId, isGuest = false }: ResultClientProps) { +function ResultClient({ tournamentId, isGuest = false, isApp = false }: ResultClientProps) { const router = useRouter(); const { tournamentData } = useGetTournament(tournamentId); const [date] = useState(() => new Date()); @@ -70,7 +72,12 @@ function ResultClient({ tournamentId, isGuest = false }: ResultClientProps) { const mainPb = isGuest && !tournamentData.isRoot ? 'pb-[145px]' : 'pb-40'; return ( -
+
@@ -156,6 +163,7 @@ function ResultClient({ tournamentId, isGuest = false }: ResultClientProps) { tournamentName={tournamentName} result={result} date={date} + isApp={isApp} />
); diff --git a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareCaptureLayer.tsx b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareCaptureLayer.tsx index 3d850e66..8410ad60 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareCaptureLayer.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareCaptureLayer.tsx @@ -5,8 +5,10 @@ import PikiLogoCart from '@/assets/images/piki-logo-cart.svg'; import type { RankedProductT } from '../../../_common/_types/tournament'; import ReceiptPaper from '../ReceiptPaper'; -const RECEIPT_ZOOM = 1.28; -const RECEIPT_RENDER_WIDTH_PX = 370 / RECEIPT_ZOOM; +/** 종이 폭은 고정하고 zoom 으로 내용만 키운다 — 렌더 폭이 줄어 결과 폭은 동일하다 */ +const RECEIPT_PAPER_WIDTH_PX = 370; +const RECEIPT_ZOOM = 1.42; +const RECEIPT_RENDER_WIDTH_PX = RECEIPT_PAPER_WIDTH_PX / RECEIPT_ZOOM; const RECEIPT_BOX_HEIGHT_PX = 748; @@ -22,7 +24,7 @@ const ReceiptShareCaptureLayer = forwardRef(null); const zoomRef = useRef(null); - /** 내용이 고정 영역을 넘치면 zoom 을 낮춰 맞춘다 */ + /** 내용이 넘치면 zoom 을 낮춘다. 종이 폭이 좁아지지 않게 렌더 폭도 함께 보정한다. */ useLayoutEffect(() => { const paper = paperRef.current; const zoomWrap = zoomRef.current; @@ -30,11 +32,13 @@ const ReceiptShareCaptureLayer = forwardRef(null); const [imageBlob, setImageBlob] = useState(null); @@ -83,12 +86,18 @@ function ReceiptShareDialog({ const isSharingLinkRef = useRef(false); const { shareToStory, isSharing } = useInstagramStoryShare(); - /** 스토리 공유는 네이티브 전용 — SSR 은 false 로 두어 hydration mismatch 를 피한다 */ - const isAppEnvironment = useSyncExternalStore( + /** + * 스토리 공유는 네이티브 전용. + * 서버가 UA 로 판정한 값을 우선 쓴다 — 클라에서만 판정하면 hydration 후에야 정해져 + * 시트가 열린 뒤 버튼이 하나 늘며 뒤늦게 나타난다. + * UA 가 없는 구버전 앱을 위해 클라 판정(window 객체)을 fallback 으로 둔다. + */ + const isWebviewByClient = useSyncExternalStore( () => () => {}, () => isWebview(), () => false ); + const isAppEnvironment = isApp || isWebviewByClient; /** 시트가 열릴 때 한 번만 캡처하고, 그 blob 을 모든 액션이 재사용한다 */ useEffect(() => { 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 07f237d7..f98398be 100644 --- a/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts +++ b/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts @@ -77,7 +77,9 @@ export const captureReceiptImage = async (element: HTMLElement): Promise = // 상품 이미지는 /_next/image?url=... 로 쿼리에만 차이가 있다. 기본 캐시 키는 쿼리를 잘라내 // 모든 상품이 첫 번째 이미지로 그려지므로 쿼리까지 키에 포함시킨다. includeQueryParams: true, - fetchRequestInit: { cache: 'force-cache', mode: 'cors' }, + // mode 를 지정하지 않는다 — 상품 이미지는 /_next/image 프록시라 same-origin 이고, + // 'cors' 로 보내면 CORS 헤더가 없는 프록시 응답이 거부돼 캐시 미스일 때만 사진이 빠진다. + fetchRequestInit: { cache: 'force-cache' }, }); if (!blob) throw new Error('영수증 이미지 변환 실패'); diff --git a/apps/web/src/app/tournament/[id]/result/page.tsx b/apps/web/src/app/tournament/[id]/result/page.tsx index ed1a0b7f..17d84cb3 100644 --- a/apps/web/src/app/tournament/[id]/result/page.tsx +++ b/apps/web/src/app/tournament/[id]/result/page.tsx @@ -2,6 +2,7 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { redirect } from 'next/navigation'; import { ROUTES } from '@/consts/route'; +import { getIsApp } from '@/utils/getIsApp'; import { getIsGuest } from '@/utils/getIsGuest'; import { getQueryClient } from '@/utils/queryClient'; @@ -31,11 +32,11 @@ async function ResultContent({ tournamentId }: { tournamentId: number }) { redirect(ROUTES.TOURNAMENT_MATCH(tournamentId)); } - const isGuest = await getIsGuest(); + const [isGuest, isApp] = await Promise.all([getIsGuest(), getIsApp()]); return ( - + ); } diff --git a/apps/web/src/utils/getIsApp.ts b/apps/web/src/utils/getIsApp.ts new file mode 100644 index 00000000..924c54c1 --- /dev/null +++ b/apps/web/src/utils/getIsApp.ts @@ -0,0 +1,15 @@ +import { headers } from 'next/headers'; + +import { isWebview } from '@/utils/webBridge'; + +/** + * RSC 전용. User-Agent 로 앱(웹뷰) 여부를 판정한다. + * + * 클라이언트에서 `useSyncExternalStore` 로 판정하면 hydration 후에야 값이 정해져 + * 앱 전용 UI 가 뒤늦게 나타난다. 서버에서 미리 내려주면 첫 렌더부터 확정된다. + */ +export const getIsApp = async (): Promise => { + const userAgent = (await headers()).get('user-agent'); + + return isWebview(userAgent); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b87dc62..49d2071d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: expo-linking: specifier: ~8.0.11 version: 8.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-modules-core: + specifier: 3.0.29 + version: 3.0.29(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-notifications: specifier: ^0.32.17 version: 0.32.17(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2)