mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
fix(share): attach captured card image when sharing to Instagram Stories
Both share modals opened the instagram-stories:// scheme without ever placing the captured PNG on the pasteboard, so Instagram opened an empty story. Replace the manual scheme launch with react-native-share shareSingle(INSTAGRAM_STORIES), which writes the image to UIPasteboard under com.instagram.sharedSticker.backgroundImage on iOS and uses the ADD_TO_STORY intent on Android. Gate the flow on the required Facebook App ID (EXPO_PUBLIC_FACEBOOK_APP_ID) and Instagram being installed, with fallback to the system share sheet, and remove the dead base64 reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f38b6f14fa
commit
04822043ae
11 files changed
+215
-62
No files matched your search
@@ -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<boolean>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user