diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example index 1c303ef..4164d80 100644 --- a/apps/mobile/.env.example +++ b/apps/mobile/.env.example @@ -13,13 +13,19 @@ EXPO_PUBLIC_API_URL=http://127.0.0.1:3003 # invoking shell. Do not put those credentials in this Expo environment file or # commit them. The test user must be disposable, onboarding-complete, and have a # matching profile in the standalone non-production PostgreSQL database. - -# Sentry error tracking (optional - get DSN from https://sentry.io) -EXPO_PUBLIC_SENTRY_DSN= - -# RevenueCat (for in-app subscriptions) -# Get your API keys from https://app.revenuecat.com -EXPO_PUBLIC_REVENUECAT_IOS_API_KEY= -EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY= -# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set) -EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro + +# Facebook App ID for Instagram Stories sharing (optional). Instagram requires +# a registered Facebook App ID for the Stories share flow; without it the +# Instagram share button falls back to the system share sheet. +# https://developers.facebook.com/docs/instagram/sharing-to-stories/ +EXPO_PUBLIC_FACEBOOK_APP_ID= + +# Sentry error tracking (optional - get DSN from https://sentry.io) +EXPO_PUBLIC_SENTRY_DSN= + +# RevenueCat (for in-app subscriptions) +# Get your API keys from https://app.revenuecat.com +EXPO_PUBLIC_REVENUECAT_IOS_API_KEY= +EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY= +# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set) +EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index aafee4f..975c231 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + diff --git a/apps/mobile/app.json b/apps/mobile/app.json index fda5b0d..a521ad7 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -298,7 +298,19 @@ } ], "./plugins/withWidgetExported.js", - "./plugins/withNativeWidget.js" + "./plugins/withNativeWidget.js", + [ + "react-native-share", + { + "ios": [ + "instagram", + "instagram-stories" + ], + "android": [ + "com.instagram.android" + ] + } + ] ], "experiments": { "typedRoutes": true diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 6909af0..6bce093 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -417,7 +417,7 @@ PODS: - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleDataTransport (10.1.0): + - GoogleDataTransport (10.1.1): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - GoogleSignIn (9.1.0): @@ -3236,6 +3236,34 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - RNShare (12.3.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - RNSVG (15.12.1): - boost - DoubleConversion @@ -3517,6 +3545,7 @@ DEPENDENCIES: - RNPurchases (from `../../../node_modules/react-native-purchases`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../../../node_modules/react-native-screens`) + - RNShare (from `../../../node_modules/react-native-share`) - RNSVG (from `../../../node_modules/react-native-svg`) - RNWorklets (from `../../../node_modules/react-native-worklets`) - SocketRocket (~> 0.7.1) @@ -3792,6 +3821,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../../../node_modules/react-native-screens" + RNShare: + :path: "../../../node_modules/react-native-share" RNSVG: :path: "../../../node_modules/react-native-svg" RNWorklets: @@ -3804,8 +3835,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 + DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea EXConstants: 18ac927605517285307ea8f7ffe6fe7ca538eb5d @@ -3851,10 +3882,10 @@ SPEC CHECKSUMS: FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0 FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41 FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b - fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17 - GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539 GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 @@ -3944,6 +3975,7 @@ SPEC CHECKSUMS: RNPurchases: ae1f8f522674a7938dd26eba6c103c40d049dfee RNReanimated: 45bfba06751010ae2f6a1cd8b9f618ac4ce1a12c RNScreens: 2e9c41cd099b1ca50136af8d57c3594214d0086a + RNShare: 61cad1e65b8e3788c97ee7c5cac4bcbb25a8df40 RNSVG: 94a1be05fab4043354bcf7104f0f9b0e2231ef05 RNWorklets: 09a47bd92d3da6e00420a060fcc86e23b745e980 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf diff --git a/apps/mobile/ios/Sauci/Info.plist b/apps/mobile/ios/Sauci/Info.plist index 78a8d59..8e27983 100644 --- a/apps/mobile/ios/Sauci/Info.plist +++ b/apps/mobile/ios/Sauci/Info.plist @@ -52,6 +52,11 @@ ITSAppUsesNonExemptEncryption + LSApplicationQueriesSchemes + + instagram + instagram-stories + LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS diff --git a/apps/mobile/package.json b/apps/mobile/package.json index cd4aa7f..ced338a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -72,6 +72,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-share": "^12.3.1", "react-native-svg": "15.12.1", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "^4.0.3", diff --git a/apps/mobile/src/__tests__/lib/instagramShare.test.ts b/apps/mobile/src/__tests__/lib/instagramShare.test.ts new file mode 100644 index 0000000..6a615a8 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/instagramShare.test.ts @@ -0,0 +1,73 @@ +import { Linking } from "react-native"; + +const mockShareSingle = jest.fn(); +const mockIsPackageInstalled = jest.fn(); + +jest.mock("react-native-share", () => ({ + __esModule: true, + default: { + shareSingle: (...args: unknown[]) => mockShareSingle(...args), + isPackageInstalled: (...args: unknown[]) => mockIsPackageInstalled(...args), + }, + Social: { InstagramStories: "instagramstories" }, +})); + +function loadShareToInstagramStories() { + let shareToInstagramStories: (uri: string) => Promise; + jest.isolateModules(() => { + shareToInstagramStories = require("../../lib/instagramShare").shareToInstagramStories; + }); + return shareToInstagramStories!; +} + +describe("shareToInstagramStories", () => { + const originalAppId = process.env.EXPO_PUBLIC_FACEBOOK_APP_ID; + + afterEach(() => { + if (originalAppId === undefined) { + delete process.env.EXPO_PUBLIC_FACEBOOK_APP_ID; + } else { + process.env.EXPO_PUBLIC_FACEBOOK_APP_ID = originalAppId; + } + }); + + it("returns false without opening Instagram when no Facebook App ID is configured", async () => { + delete process.env.EXPO_PUBLIC_FACEBOOK_APP_ID; + const share = loadShareToInstagramStories(); + + await expect(share("file:///tmp/card.png")).resolves.toBe(false); + expect(mockShareSingle).not.toHaveBeenCalled(); + }); + + it("returns false when Instagram is not installed", async () => { + process.env.EXPO_PUBLIC_FACEBOOK_APP_ID = "123456"; + jest.spyOn(Linking, "canOpenURL").mockResolvedValue(false); + const share = loadShareToInstagramStories(); + + await expect(share("file:///tmp/card.png")).resolves.toBe(false); + expect(mockShareSingle).not.toHaveBeenCalled(); + }); + + it("shares the captured image as the story background when Instagram is available", async () => { + process.env.EXPO_PUBLIC_FACEBOOK_APP_ID = "123456"; + jest.spyOn(Linking, "canOpenURL").mockResolvedValue(true); + mockShareSingle.mockResolvedValue({ success: true }); + const share = loadShareToInstagramStories(); + + await expect(share("file:///tmp/card.png")).resolves.toBe(true); + expect(mockShareSingle).toHaveBeenCalledWith({ + social: "instagramstories", + appId: "123456", + backgroundImage: "file:///tmp/card.png", + }); + }); + + it("returns false when the native share fails", async () => { + process.env.EXPO_PUBLIC_FACEBOOK_APP_ID = "123456"; + jest.spyOn(Linking, "canOpenURL").mockResolvedValue(true); + mockShareSingle.mockRejectedValue(new Error("share failed")); + const share = loadShareToInstagramStories(); + + await expect(share("file:///tmp/card.png")).resolves.toBe(false); + }); +}); diff --git a/apps/mobile/src/components/share/SharePreviewModal.tsx b/apps/mobile/src/components/share/SharePreviewModal.tsx index e4650be..b3fd6fe 100644 --- a/apps/mobile/src/components/share/SharePreviewModal.tsx +++ b/apps/mobile/src/components/share/SharePreviewModal.tsx @@ -6,7 +6,6 @@ import { Modal, TouchableOpacity, Platform, - Linking, Share, useWindowDimensions, Image, @@ -23,8 +22,8 @@ import { Ionicons } from "@expo/vector-icons"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import ViewShot from "react-native-view-shot"; import * as Sharing from "expo-sharing"; -import * as FileSystem from "expo-file-system/legacy"; import { colors, gradients, radius, typography, spacing, blur } from "../../theme"; +import { shareToInstagramStories } from "../../lib/instagramShare"; const logo = require("../../../assets/logo.png"); @@ -87,31 +86,9 @@ export function SharePreviewModal({ const handleInstagramShare = async () => { if (!capturedUri) return; - try { - // Check if Instagram is installed - const instagramUrl = 'instagram://app'; - const canOpenInstagram = await Linking.canOpenURL(instagramUrl); - - if (canOpenInstagram && Platform.OS === 'ios') { - // For iOS, we can use the Instagram Stories URL scheme - // First, we need to copy the image to a location Instagram can access - const base64 = await FileSystem.readAsStringAsync(capturedUri, { - encoding: FileSystem.EncodingType.Base64, - }); - - // Use Instagram Stories URL scheme - const instagramStoriesUrl = `instagram-stories://share?source_application=com.sauci.app`; - - // Try to open Instagram Stories with the image - // Note: This requires the image to be in the pasteboard on iOS - await Linking.openURL(instagramStoriesUrl); - } else { - // Fallback to regular sharing - await handleMoreShare(); - } - } catch (error) { - console.error('Instagram share error:', error); - // Fallback to regular sharing + const shared = await shareToInstagramStories(capturedUri); + if (!shared) { + // Instagram Stories sharing unavailable - fall back to the share sheet await handleMoreShare(); } }; diff --git a/apps/mobile/src/features/quiz/components/QuizShareModal.tsx b/apps/mobile/src/features/quiz/components/QuizShareModal.tsx index b01ff51..3919fa5 100644 --- a/apps/mobile/src/features/quiz/components/QuizShareModal.tsx +++ b/apps/mobile/src/features/quiz/components/QuizShareModal.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from "react"; import { - Linking, Modal, Platform, Share, @@ -16,8 +15,8 @@ import { Ionicons } from "@expo/vector-icons"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import ViewShot from "react-native-view-shot"; import * as Sharing from "expo-sharing"; -import * as FileSystem from "expo-file-system/legacy"; import { colors, spacing, typography, blur } from "../../../theme"; +import { shareToInstagramStories } from "../../../lib/instagramShare"; import { Events } from "../../../lib/analytics"; import { QuizResultsShareCard } from "./QuizResultsShareCard"; @@ -63,22 +62,11 @@ export function QuizShareModal({ visible, onClose, scorePercent }: QuizShareModa const handleInstagramShare = async () => { if (!capturedUri) return; - try { - const instagramUrl = "instagram://app"; - const canOpenInstagram = await Linking.canOpenURL(instagramUrl); - - if (canOpenInstagram && Platform.OS === "ios") { - await FileSystem.readAsStringAsync(capturedUri, { - encoding: FileSystem.EncodingType.Base64, - }); - const instagramStoriesUrl = "instagram-stories://share?source_application=com.sauci.app"; - await Linking.openURL(instagramStoriesUrl); - Events.quizShared(); - } else { - await handleMoreShare(); - } - } catch (error) { - console.error("Instagram share error:", error); + const shared = await shareToInstagramStories(capturedUri); + if (shared) { + Events.quizShared(); + } else { + // Instagram Stories sharing unavailable - fall back to the share sheet await handleMoreShare(); } }; diff --git a/apps/mobile/src/lib/instagramShare.ts b/apps/mobile/src/lib/instagramShare.ts new file mode 100644 index 0000000..c520a6f --- /dev/null +++ b/apps/mobile/src/lib/instagramShare.ts @@ -0,0 +1,48 @@ +import { Linking, Platform } from "react-native"; +import Share, { Social } from "react-native-share"; + +// Instagram requires a registered Facebook App ID for the Stories share flow +// (https://developers.facebook.com/docs/instagram/sharing-to-stories/). +const facebookAppId = process.env.EXPO_PUBLIC_FACEBOOK_APP_ID; + +const INSTAGRAM_ANDROID_PACKAGE = "com.instagram.android"; + +async function isInstagramStoriesAvailable(): Promise { + if (!facebookAppId) return false; + + try { + if (Platform.OS === "ios") { + return await Linking.canOpenURL("instagram-stories://share"); + } + if (Platform.OS === "android") { + const { isInstalled } = await Share.isPackageInstalled(INSTAGRAM_ANDROID_PACKAGE); + return isInstalled; + } + return false; + } catch { + return false; + } +} + +/** + * Share a locally captured image (file:// URI) to Instagram Stories as the + * story background. Returns true when the share was handed off to Instagram, + * false when Instagram Stories sharing is unavailable (not installed, no + * Facebook App ID configured, or the user dismissed the native prompt) so + * callers can fall back to the system share sheet. + */ +export async function shareToInstagramStories(imageUri: string): Promise { + if (!(await isInstagramStoriesAvailable())) return false; + + try { + const result = await Share.shareSingle({ + social: Social.InstagramStories, + appId: facebookAppId as string, + backgroundImage: imageUri, + }); + return result.success !== false; + } catch (error) { + console.error("Instagram Stories share error:", error); + return false; + } +} diff --git a/package-lock.json b/package-lock.json index 4112bb9..7e8a87b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -381,6 +381,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-share": "^12.3.1", "react-native-svg": "15.12.1", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "^4.0.3", @@ -25256,6 +25257,15 @@ "react-native": "*" } }, + "node_modules/react-native-share": { + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-12.3.1.tgz", + "integrity": "sha512-mRVRie0qKbtj+jWqJ2POPkeSd8SxnMp6aazFZVmq8ZbkUGtQLENR/L58ky8zH4VUEF/WYRmdBiBHWHVtzOewtQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/react-native-svg": { "version": "15.12.1", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz",