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",