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
6 changes: 6 additions & 0 deletions apps/app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@
"nativeAppKey": "326734abdf8de5c7e090a2fc72e1dce6"
}
],
[
"./plugins/withInstagramStoryShare.js",
{
"facebookAppId": "2060093567931900"
}
],
[
"@sentry/react-native",
{
Expand Down
6 changes: 6 additions & 0 deletions apps/app/modules/instagram-story/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["InstagramStoryModule"]
}
}
20 changes: 20 additions & 0 deletions apps/app/modules/instagram-story/ios/InstagramStory.podspec
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions apps/app/modules/instagram-story/ios/InstagramStoryModule.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
7 changes: 7 additions & 0 deletions apps/app/modules/instagram-story/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "instagram-story",
"version": "1.0.0",
"description": "iOS 인스타그램 스토리 공유 (전용 pasteboard 키)",
"main": "src/index.ts",
"private": true
}
45 changes: 45 additions & 0 deletions apps/app/modules/instagram-story/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<ShareInstagramStoryStatusT>;
Comment on lines +23 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

멀티라인 매개변수에 후행 쉼표를 추가하세요.

Line 25와 Line 40의 마지막 매개변수 뒤에 ES5 후행 쉼표가 없습니다. TypeScript 스타일 규칙에 맞게 추가하세요.

수정 예시
   shareBackgroundImage: (
     base64: string,
-    facebookAppId: string
+    facebookAppId: string,
   ) => Promise<ShareInstagramStoryStatusT>;
@@
 export const shareInstagramStoryBackground = async (
   base64: string,
-  facebookAppId: string
+  facebookAppId: string,
 ): Promise<ShareInstagramStoryStatusT> => {

Also applies to: 38-41

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/app/modules/instagram-story/src/index.ts` around lines 23 - 26, Update
the multiline parameter lists for shareBackgroundImage and the similarly
structured declaration around lines 38–41 by adding trailing commas after their
final parameters, including facebookAppId, to comply with the TypeScript style
rule.

Source: Coding guidelines

};

/** iOS 전용 모듈 — 안드로이드/미포함 빌드에서는 null */
const InstagramStoryModule = requireOptionalNativeModule<InstagramStoryModuleT>('InstagramStory');

export const isInstagramStoryModuleAvailable = InstagramStoryModule !== null;

/**
* 인스타그램 스토리 편집 화면으로 배경 이미지를 전달한다 (iOS).
* 모듈이 없거나 App ID 가 비어 있으면 'error' 를 돌려준다.
*/
export const shareInstagramStoryBackground = async (
base64: string,
facebookAppId: string
): Promise<ShareInstagramStoryStatusT> => {
if (!InstagramStoryModule || !facebookAppId) return 'error';

return InstagramStoryModule.shareBackgroundImage(base64, facebookAppId);
};
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions apps/app/plugins/withInstagramStoryShare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const { withInfoPlist } = require('@expo/config-plugins');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

pnpm install --frozen-lockfile
pnpm exec eslint apps/app/plugins/withInstagramStoryShare.js

Repository: TeamPiKi/client

Length of output: 2860


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- plugin ---'
cat -n apps/app/plugins/withInstagramStoryShare.js

printf '%s\n' '--- nearby config plugins ---'
find apps/app/plugins -maxdepth 1 -type f -print | sort | while read -r file; do
  printf '\n### %s\n' "$file"
  sed -n '1,12p' "$file"
done

printf '%s\n' '--- ESLint configuration references ---'
rg -n --hidden --glob '!node_modules/**' \
  'no-require-imports|no-undef|eslint|withInfoPlist|module\.exports|export default' \
  .eslintrc* eslint.config.* package.json apps/app/package.json apps/app/plugins 2>/dev/null || true

printf '%s\n' '--- package scripts ---'
node -e "const p=require('./package.json'); console.log(JSON.stringify(p.scripts,null,2))"

Repository: TeamPiKi/client

Length of output: 5707


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ESLint configuration ---'
cat -n eslint.config.mjs | sed -n '1,90p'

printf '%s\n' '--- app scripts and lint configuration ---'
node - <<'JS'
const root = require('./package.json');
const app = require('./apps/app/package.json');
console.log('root scripts:', JSON.stringify(root.scripts, null, 2));
console.log('app scripts:', JSON.stringify(app.scripts, null, 2));
JS

printf '%s\n' '--- effective rules for the plugin ---'
pnpm exec eslint --print-config apps/app/plugins/withInstagramStoryShare.js |
  node -e "
    let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>{
      const c=JSON.parse(s);
      for (const k of ['no-undef','`@typescript-eslint/no-require-imports`']) {
        console.log(k + ':', JSON.stringify(c.rules[k]));
      }
    });
  "

printf '%s\n' '--- all CommonJS plugin entry points ---'
rg -n -U '^const .*require\\(|^module\\.exports' apps/app/plugins

Repository: TeamPiKi/client

Length of output: 3825


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- lint results for all Config Plugin files ---'
pnpm exec eslint apps/app/plugins --format stylish || true

printf '%s\n' '--- CommonJS entry points ---'
rg -n '^(const .*require\\(|module\\.exports)' apps/app/plugins

printf '%s\n' '--- Node globals available to ESLint ---'
node - <<'JS'
const globals = require('globals');
console.log(JSON.stringify({
  require: globals.node.require,
  module: globals.node.module,
}, null, 2));
JS

printf '%s\n' '--- Expo lint command resolution ---'
pnpm --filter piki-app exec expo lint --help 2>&1 | sed -n '1,80p' || true

Repository: TeamPiKi/client

Length of output: 2712


Config Plugin 파일에 CommonJS ESLint 예외를 적용하세요.

apps/app/plugins의 CommonJS 엔트리 포인트에서 require()module.exports가 lint 오류를 발생시킵니다. 해당 디렉터리에 Node 전역을 적용하고 @typescript-eslint/no-require-imports를 비활성화하세요.

🧰 Tools
🪛 ESLint

[error] 1-1: A require() style import is forbidden.

(@typescript-eslint/no-require-imports)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/app/plugins/withInstagramStoryShare.js` at line 1, Apply a file-level
ESLint override to the CommonJS entry point using Node globals and disabling
`@typescript-eslint/no-require-imports`, while preserving the existing
withInfoPlist import and module.exports behavior.

Source: Linters/SAST tools


/**
* 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;
25 changes: 14 additions & 11 deletions apps/app/utils/handleInstagramStory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,16 +39,13 @@ const writeStoryImageFile = (base64: string) => {
return file;
};

/** iOS — 인스타그램이 pasteboard 에서 이미지를 읽어가므로 딥링크보다 먼저 복사해야 한다 */
const shareToStoryOnIos = async (base64: string): Promise<ShareInstagramStoryStatusT> => {
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<ShareInstagramStoryStatusT> =>
shareInstagramStoryBackground(base64, FACEBOOK_APP_ID);

/** Android — FileProvider 로 노출한 content:// URI 여야 인스타그램이 읽을 수 있다 */
const shareToStoryOnAndroid = async (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -70,7 +72,12 @@ function ResultClient({ tournamentId, isGuest = false }: ResultClientProps) {
const mainPb = isGuest && !tournamentData.isRoot ? 'pb-[145px]' : 'pb-40';

return (
<main className={cn('flex min-h-dvh flex-col overflow-x-hidden bg-bg-layer-basement pt-padding-top', mainPb)}>
<main
className={cn(
'flex min-h-dvh flex-col overflow-x-hidden bg-bg-layer-basement pt-padding-top',
mainPb
)}
>
<Header center="토너먼트 결과" centerClassName="heading-1-bold" />

<div className="mx-auto mt-4 flex min-h-0 w-full max-w-120 flex-1 flex-col gap-3">
Expand Down Expand Up @@ -156,6 +163,7 @@ function ResultClient({ tournamentId, isGuest = false }: ResultClientProps) {
tournamentName={tournamentName}
result={result}
date={date}
isApp={isApp}
/>
</main>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -22,19 +24,21 @@ const ReceiptShareCaptureLayer = forwardRef<HTMLDivElement, ReceiptShareCaptureL
const paperRef = useRef<HTMLDivElement>(null);
const zoomRef = useRef<HTMLDivElement>(null);

/** 내용이 고정 영역을 넘치면 zoom 을 낮춰 맞춘다 */
/** 내용이 넘치면 zoom 을 낮춘다. 종이 폭이 좁아지지 않게 렌더 폭도 함께 보정한다. */
useLayoutEffect(() => {
const paper = paperRef.current;
const zoomWrap = zoomRef.current;
if (!paper || !zoomWrap) return;

/** 이전 축소가 남아 있으면 높이를 잘못 재므로 기준값으로 되돌리고 측정 */
zoomWrap.style.zoom = String(RECEIPT_ZOOM);
zoomWrap.style.width = `${RECEIPT_RENDER_WIDTH_PX}px`;
const paperHeight = paper.scrollHeight;
if (!paperHeight) return;

const fitZoom = Math.min(RECEIPT_ZOOM, RECEIPT_BOX_HEIGHT_PX / paperHeight);
zoomWrap.style.zoom = String(fitZoom);
zoomWrap.style.width = `${RECEIPT_PAPER_WIDTH_PX / fitZoom}px`;
}, [result, tournamentName, date]);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type ReceiptShareDialogProps = {
tournamentName: string;
result: RankedProductT[];
date: Date;
/** 서버가 UA 로 판정한 앱 여부 — 스토리 공유 버튼 노출에 쓴다 */
isApp?: boolean;
};

type ShareActionProps = {
Expand Down Expand Up @@ -74,6 +76,7 @@ function ReceiptShareDialog({
tournamentName,
result,
date,
isApp = false,
}: ReceiptShareDialogProps) {
const captureLayerRef = useRef<HTMLDivElement | null>(null);
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export const captureReceiptImage = async (element: HTMLElement): Promise<Blob> =
// 상품 이미지는 /_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('영수증 이미지 변환 실패');

Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/tournament/[id]/result/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
<ResultClient tournamentId={tournamentId} isGuest={isGuest} />
<ResultClient tournamentId={tournamentId} isGuest={isGuest} isApp={isApp} />
</HydrationBoundary>
);
}
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/utils/getIsApp.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
const userAgent = (await headers()).get('user-agent');

return isWebview(userAgent);
};
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading