@rocapine/rn-social-share 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/capture.ts","../src/createSharer.ts","../src/channels/_shareSheet.ts","../src/channels/systemSheet.ts","../src/channels/whatsapp.ts","../src/channels/copyLink.ts","../src/channels/instagramStories.ts","../src/channels/sms.ts","../src/channels/tiktok.ts","../src/channels/saveToLibrary.ts","../src/react/useShareCard.ts","../src/react/useScreenshotTrigger.ts","../src/react/screenshotDebounce.ts","../src/react/imageReadiness.ts"],"sourcesContent":["// Core (pure, no UI)\nexport * from \"./types\";\nexport { captureCardImage, type CaptureOptions } from \"./capture\";\nexport { createSharer } from \"./createSharer\";\n\n// Channels (modular registry)\nexport * from \"./channels\";\n\n// React layer (optional hooks + helpers)\nexport * from \"./react\";\n","import { cacheDirectory, copyAsync, deleteAsync } from \"expo-file-system/legacy\";\nimport type { RefObject } from \"react\";\nimport { captureRef } from \"react-native-view-shot\";\n\nexport type CaptureOptions = {\n /**\n * Branded file name (with extension) the captured image is copied to, so every\n * consumer — the share sheet, an SMS attachment, TikTok, the saved photo — shows a\n * clean name (e.g. \"unchaind-invite.png\") instead of view-shot's random UUID.\n */\n fileName: string;\n /** Output width in px. Default 1080 (story resolution at 3× of a 360pt card). */\n width?: number;\n /** Output height in px. Default 1920. */\n height?: number;\n /** Image format. Default \"png\". */\n format?: \"png\" | \"jpg\";\n /** 0..1. Default 1. */\n quality?: number;\n /** Called with the underlying error if capture fails (the function still resolves undefined). */\n onError?: (error: unknown) => void;\n};\n\n/**\n * Captures an off-screen view (via its ref) to an image file at story resolution, then\n * copies it to a branded file name. Returns the file URI, or `undefined` on failure so\n * callers can fall back to a text-only share.\n *\n * This is the single, deduplicated version of the capture logic both apps had copied\n * verbatim. Extracted as a plain async function so it is testable without rendering.\n *\n * Note (ROC-2883): view-shot must not run until every async image in the card has\n * painted, or a late-loading image is absent from the file. Coordinate readiness with\n * `areAllImagesLoaded` before calling this.\n */\nexport async function captureCardImage(\n ref: RefObject<unknown>,\n options: CaptureOptions,\n): Promise<string | undefined> {\n const { fileName, width = 1080, height = 1920, format = \"png\", quality = 1, onError } = options;\n try {\n // view-shot's temp file is managed and can't be moved reliably — copy it instead.\n const tmpUri = await captureRef(ref as never, { format, quality, width, height });\n const namedUri = `${cacheDirectory}${fileName}`;\n await deleteAsync(namedUri, { idempotent: true });\n await copyAsync({ from: tmpUri, to: namedUri });\n return namedUri;\n } catch (error) {\n onError?.(error);\n return undefined;\n }\n}\n","import type {\n CreateSharerOptions,\n ShareChannel,\n ShareEvent,\n SharePayload,\n ShareOutcome,\n Sharer,\n} from \"./types\";\n\nconst DEFAULT_FALLBACK_ID = \"system-sheet\";\n\n/**\n * Builds a sharer from a list of channels. This is the abstraction layer between the app\n * and react-native-share / the OpenSDKs: the app only ever calls `sharer.share(id, payload)`\n * and adds/removes channels by editing the `channels` array.\n *\n * Routing per share:\n * 1. Emit `share_started`.\n * 2. Unknown id, or `isAvailable()` false/throws → `share_unavailable`, route to fallback.\n * 3. `share()` throws → `share_failed`, route to fallback.\n * 4. Emit `share_completed` with the channel that actually handled it.\n *\n * The fallback (default: the channel registered as \"system-sheet\") is never used to route\n * back into itself, so a missing/failed fallback yields a non-completed outcome instead of\n * looping.\n */\nexport function createSharer(options: CreateSharerOptions): Sharer {\n const { channels, onEvent } = options;\n\n const registry = new Map<string, ShareChannel>();\n for (const channel of channels) {\n if (registry.has(channel.id)) {\n throw new Error(`[rn-social-share] Duplicate channel id \"${channel.id}\"`);\n }\n registry.set(channel.id, channel);\n }\n\n const fallbackId = options.fallbackChannelId ?? DEFAULT_FALLBACK_ID;\n const fallbackChannel = registry.get(fallbackId);\n\n const emit = (event: ShareEvent): void => {\n try {\n onEvent?.(event);\n } catch {\n // Analytics/logging must never break a share.\n }\n };\n\n const emitCompleted = (outcome: ShareOutcome): void => {\n emit({\n type: \"share_completed\",\n channel: outcome.channel,\n fellBack: outcome.fellBack ?? false,\n targetApp: outcome.targetApp,\n });\n };\n\n const runFallback = async (requestedId: string, payload: SharePayload): Promise<ShareOutcome> => {\n if (!fallbackChannel || fallbackChannel.id === requestedId) {\n return { completed: false, channel: requestedId, fellBack: false };\n }\n try {\n const outcome = await fallbackChannel.share(payload);\n return { ...outcome, channel: fallbackChannel.id, fellBack: true };\n } catch {\n return { completed: false, channel: fallbackChannel.id, fellBack: true };\n }\n };\n\n const share = async (channelId: string, payload: SharePayload): Promise<ShareOutcome> => {\n emit({ type: \"share_started\", channel: channelId, hasImage: Boolean(payload.imageUri) });\n\n const channel = registry.get(channelId);\n\n let available = channel !== undefined;\n if (channel) {\n try {\n available = await channel.isAvailable(payload);\n } catch {\n available = false;\n }\n }\n\n if (!channel || !available) {\n emit({ type: \"share_unavailable\", channel: channelId });\n const outcome = await runFallback(channelId, payload);\n emitCompleted(outcome);\n return outcome;\n }\n\n try {\n const outcome = await channel.share(payload);\n emitCompleted(outcome);\n return outcome;\n } catch (error) {\n emit({ type: \"share_failed\", channel: channelId, error });\n const outcome = await runFallback(channelId, payload);\n emitCompleted(outcome);\n return outcome;\n }\n };\n\n return {\n channelIds: channels.map((channel) => channel.id),\n has: (channelId) => registry.has(channelId),\n isAvailable: async (channelId, payload) => {\n const channel = registry.get(channelId);\n if (!channel) {\n return false;\n }\n try {\n return await channel.isAvailable(payload);\n } catch {\n return false;\n }\n },\n share,\n };\n}\n","import Share from \"react-native-share\";\nimport type { ShareOutcome, SharePayload } from \"../types\";\n\ntype ShareOpenResult = { dismissedAction?: boolean; app?: string | null };\n\n/**\n * Opens the native OS share sheet with the image + caption + branded file name. Shared by\n * the `systemSheet` and `whatsapp` channels and used as the TikTok fallback. With\n * `failOnCancel: false`, a user cancel resolves (with `dismissedAction`) instead of throwing.\n */\nexport async function openSystemShareSheet(\n payload: SharePayload,\n channelId: string,\n): Promise<ShareOutcome> {\n const result = (await Share.open({\n message: payload.message,\n url: payload.imageUri,\n type: payload.imageUri ? \"image/png\" : undefined,\n filename: payload.fileName,\n failOnCancel: false,\n })) as ShareOpenResult;\n\n if (result?.dismissedAction) {\n return { completed: false, channel: channelId };\n }\n return { completed: true, channel: channelId, targetApp: result?.app ?? null };\n}\n","import type { ShareChannel } from \"../types\";\nimport { openSystemShareSheet } from \"./_shareSheet\";\n\n/**\n * The generic native share sheet. Always available, so it doubles as the default fallback\n * (register it with the default id \"system-sheet\" to have `createSharer` pick it up).\n */\nexport const systemSheet = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"system-sheet\";\n return {\n id,\n isAvailable: async () => true,\n share: (payload) => openSystemShareSheet(payload, id),\n };\n};\n","import { Linking } from \"react-native\";\nimport type { ShareChannel } from \"../types\";\nimport { openSystemShareSheet } from \"./_shareSheet\";\n\nconst WHATSAPP_PROBE_URL = \"whatsapp://send\";\n\n/**\n * WhatsApp — intentionally routed through the system share sheet. iOS has no reliable\n * direct deep link that carries the full text AND an image: both `whatsapp://send?text=`\n * and `wa.me` drop everything but the URL on real devices, and RNShare's WhatsApp-text\n * path builds an invalid URL (literal spaces). The share sheet's WhatsApp extension is the\n * only dependable path. `isAvailable` still probes for the app so the UI can hide the\n * button when WhatsApp isn't installed.\n *\n * iOS: requires `whatsapp` in LSApplicationQueriesSchemes (see `withSocialShare`).\n */\nexport const whatsapp = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"whatsapp\";\n return {\n id,\n isAvailable: () => Linking.canOpenURL(WHATSAPP_PROBE_URL).catch(() => false),\n share: (payload) => openSystemShareSheet(payload, id),\n };\n};\n","import type { ShareChannel } from \"../types\";\n\n/**\n * Copies `payload.link` to the clipboard. Not a \"share\" in the OS sense, but modeled as a\n * channel so it slots into the same UI row and analytics funnel as everything else.\n *\n * `expo-clipboard` is a lazy require so importing the channel barrel never forces the\n * dependency on apps that don't use this channel.\n */\nexport const copyLink = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"link\";\n return {\n id,\n isAvailable: async (payload) => typeof payload.link === \"string\" && payload.link.length > 0,\n share: async (payload) => {\n if (!payload.link) {\n return { completed: false, channel: id };\n }\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const Clipboard = require(\"expo-clipboard\");\n await Clipboard.setStringAsync(payload.link);\n return { completed: true, channel: id };\n },\n };\n};\n","import { Linking } from \"react-native\";\nimport Share, { type ShareSingleOptions } from \"react-native-share\";\nimport type { ShareChannel } from \"../types\";\n\nconst INSTAGRAM_STORIES_URL = \"instagram-stories://share\";\n\n/**\n * Instagram Stories with the captured image as the full-bleed background.\n *\n * `appId` is required and app-specific — it is the Facebook/Meta App ID tied to your app\n * (mandatory for the Stories deep link since Jan 2023). Pass your own; the two apps that\n * seeded this package use different ids.\n *\n * iOS: requires `instagram-stories` in LSApplicationQueriesSchemes (see `withSocialShare`)\n * for `isAvailable` to detect Instagram. When unavailable, `createSharer` routes to the\n * fallback channel.\n */\nexport const instagramStories = (options: { appId: string; id?: string }): ShareChannel => {\n const id = options.id ?? \"instagram\";\n return {\n id,\n isAvailable: async (payload) => {\n if (!payload.imageUri) {\n return false;\n }\n return Linking.canOpenURL(INSTAGRAM_STORIES_URL).catch(() => false);\n },\n share: async (payload) => {\n if (!payload.imageUri) {\n throw new Error(\"[rn-social-share] instagramStories requires payload.imageUri\");\n }\n await Share.shareSingle({\n social: Share.Social.INSTAGRAM_STORIES,\n backgroundImage: payload.imageUri,\n appId: options.appId,\n } as ShareSingleOptions);\n return { completed: true, channel: id };\n },\n };\n};\n","import Share, { type ShareSingleOptions } from \"react-native-share\";\nimport type { ShareChannel } from \"../types\";\n\n/**\n * Native SMS/Messages composer with the full text body and the card image attached.\n * `isAvailable` is optimistic (true); a device without SMS makes `shareSingle` throw, and\n * `createSharer` then routes to the fallback channel.\n */\nexport const sms = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"messages\";\n return {\n id,\n isAvailable: async () => true,\n share: async (payload) => {\n await Share.shareSingle({\n social: Share.Social.SMS,\n message: payload.message,\n url: payload.imageUri,\n } as ShareSingleOptions);\n return { completed: true, channel: id };\n },\n };\n};\n","import type { ShareChannel } from \"../types\";\nimport { openSystemShareSheet } from \"./_shareSheet\";\n\nexport type TikTokChannelOptions = {\n id?: string;\n /** Called if the OpenSDK is missing or rejects (before falling back to the system sheet). */\n onError?: (error: unknown) => void;\n};\n\n/**\n * TikTok via `tiktok-opensdk-react-native`, fire-and-forget. Opening TikTok IS the action —\n * we never await the SDK's result promise because, with no AppDelegate callback wired, it\n * may never settle. We report `completed` optimistically once the share is kicked off; a\n * *real* rejection (e.g. TikTok not installed) still routes to the system sheet so the user\n * can share.\n *\n * The SDK is a lazy require and optional: if it isn't installed, this channel simply falls\n * back to the system sheet. Native setup (client key, FileProvider, package visibility)\n * lives in the app's own TikTok config plugin — see the README.\n *\n * API (tiktok-opensdk-react-native@0.10.x): default export\n * TikTokOpenSDK.share(mediaPaths: string[], isImage?: boolean, isGreenScreen?: boolean)\n */\nexport const tiktok = (options: TikTokChannelOptions = {}): ShareChannel => {\n const id = options.id ?? \"tiktok\";\n\n const fallback = (payload: Parameters<ShareChannel[\"share\"]>[0]) =>\n openSystemShareSheet(payload, id).then((outcome) => ({ ...outcome, fellBack: true }));\n\n return {\n id,\n isAvailable: async (payload) => Boolean(payload.imageUri),\n share: async (payload) => {\n if (!payload.imageUri) {\n return fallback(payload);\n }\n try {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const mod = require(\"tiktok-opensdk-react-native\");\n const TikTokOpenSDK = mod?.default ?? mod;\n if (TikTokOpenSDK && typeof TikTokOpenSDK.share === \"function\") {\n // Fire-and-forget. Fall back only on a real rejection; the promise-never-settles\n // case simply never runs `.catch`, which is the intended behavior.\n TikTokOpenSDK.share([payload.imageUri], true, false).catch((error: unknown) => {\n options.onError?.(error);\n void openSystemShareSheet(payload, id);\n });\n return { completed: true, channel: id };\n }\n } catch (error) {\n options.onError?.(error);\n }\n return fallback(payload);\n },\n };\n};\n","import type { ShareChannel } from \"../types\";\n\n/**\n * Saves the image to the device photo library via `expo-media-library` (add-only / write\n * permission, which avoids needing a full photo-library read description on iOS). Returns\n * `{ completed: false, permissionDenied: true }` when the user denies access, so the host UI\n * can show an \"open settings\" prompt.\n *\n * `expo-media-library` is a lazy require and optional — only apps that register this channel\n * need it installed.\n */\nexport const saveToLibrary = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"save\";\n return {\n id,\n isAvailable: async (payload) => Boolean(payload.imageUri),\n share: async (payload) => {\n if (!payload.imageUri) {\n throw new Error(\"[rn-social-share] saveToLibrary requires payload.imageUri\");\n }\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const MediaLibrary = require(\"expo-media-library\");\n const current = await MediaLibrary.getPermissionsAsync(true);\n let granted = current.granted;\n if (!granted) {\n const requested = await MediaLibrary.requestPermissionsAsync(true);\n granted = requested.granted;\n }\n if (!granted) {\n return { completed: false, channel: id, permissionDenied: true };\n }\n await MediaLibrary.saveToLibraryAsync(payload.imageUri);\n return { completed: true, channel: id };\n },\n };\n};\n","import { useCallback, useRef } from \"react\";\nimport type { View } from \"react-native\";\nimport { captureCardImage, type CaptureOptions } from \"../capture\";\n\n/**\n * Headless capture hook: gives you a `ref` to attach to your off-screen card view and a\n * `capture()` that renders it to a branded image file (or `undefined` on failure).\n *\n * The card visual itself stays in the app — the package only captures whatever you point\n * the ref at. Render the card off-screen (e.g. `position: \"absolute\", left: -10000`) and\n * gate `capture()` on `areAllImagesLoaded` if the card has async images.\n *\n * Need two variants (e.g. rounded for the sheet, square full-bleed for Stories)? Call this\n * hook twice, or call `captureCardImage` directly with each ref.\n */\nexport function useShareCard(options: CaptureOptions) {\n const ref = useRef<View>(null);\n const { fileName, width, height, format, quality, onError } = options;\n\n const capture = useCallback(\n () => captureCardImage(ref, { fileName, width, height, format, quality, onError }),\n [fileName, width, height, format, quality, onError],\n );\n\n return { ref, capture };\n}\n","import { useEffect, useRef } from \"react\";\nimport { screenshotGate } from \"./screenshotDebounce\";\n\n/**\n * Calls `onScreenshot` when the user takes a screenshot, while `enabled` is true — the\n * \"share on screenshot\" trigger from the Expo Marathon pattern. Dedup is enforced by a\n * shared, app-wide gate so one screenshot fires at most one handler even when iOS\n * double-fires or several instances are mounted (ROC-2854).\n *\n * No-ops silently where screenshot detection is unavailable (iOS supported; Android only\n * API 34+) or when `expo-screen-capture` isn't installed — the lazy require keeps that\n * dependency optional, and an explicit share button remains the fallback.\n */\nexport function useScreenshotTrigger(enabled: boolean, onScreenshot: () => void): void {\n const onScreenshotRef = useRef(onScreenshot);\n onScreenshotRef.current = onScreenshot;\n\n useEffect(\n function subscribeToScreenshots() {\n if (!enabled) {\n return;\n }\n let subscription: { remove: () => void } | undefined;\n try {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const { addScreenshotListener } = require(\"expo-screen-capture\");\n subscription = addScreenshotListener(() => {\n if (screenshotGate(Date.now())) {\n onScreenshotRef.current();\n }\n });\n } catch {\n // Screenshot detection unavailable / module not installed — silent fail.\n }\n return () => subscription?.remove();\n },\n [enabled],\n );\n}\n","export const SCREENSHOT_DEBOUNCE_MS = 1000;\n\nexport function shouldHandleScreenshot(\n lastHandledAt: number | null,\n now: number,\n windowMs: number = SCREENSHOT_DEBOUNCE_MS,\n): boolean {\n if (lastHandledAt === null) {\n return true;\n }\n return now - lastHandledAt >= windowMs;\n}\n\n/**\n * A screenshot is a single OS-wide event, but one screenshot can reach a handler more than\n * once: iOS double-fires the notification, and release builds can mount more than one\n * `useScreenshotTrigger` instance (ROC-2854). A per-instance debounce can't dedupe across\n * instances. This gate holds the last-handled timestamp so that, no matter how many callers\n * react to the same screenshot, `gate(now)` returns `true` at most once per `windowMs`.\n *\n * The hook uses a single shared gate (`screenshotGate`); the factory exists so tests (and\n * apps that want isolation) get their own instance.\n */\nexport function createScreenshotGate(windowMs: number = SCREENSHOT_DEBOUNCE_MS) {\n let lastHandledAt: number | null = null;\n return function tryHandleScreenshot(now: number): boolean {\n if (shouldHandleScreenshot(lastHandledAt, now, windowMs)) {\n lastHandledAt = now;\n return true;\n }\n return false;\n };\n}\n\n/** Single app-wide screenshot gate shared by every `useScreenshotTrigger`. */\nexport const screenshotGate = createScreenshotGate();\n","/**\n * A shareable card often contains several asynchronously-loaded images (a fruit\n * illustration, a logo…). view-shot must not capture until every one has painted, or an\n * image that loads after the capture fires is absent from the file. This was ROC-2883: the\n * capture only awaited one image, so the logo was frequently missing from the export.\n *\n * Track loaded keys in a Set as each `<Image onLoad>` fires, then gate the capture on\n * `areAllImagesLoaded(loaded, REQUIRED_KEYS)`.\n */\nexport function areAllImagesLoaded(\n loaded: ReadonlySet<string>,\n required: readonly string[],\n): boolean {\n return required.every((key) => loaded.has(key));\n}\n"],"mappings":"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","names":["Share","import_react_native","import_react_native_share","Share","import_react_native_share","Share","import_react"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,348 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/capture.ts
9
+ import { cacheDirectory, copyAsync, deleteAsync } from "expo-file-system/legacy";
10
+ import { captureRef } from "react-native-view-shot";
11
+ async function captureCardImage(ref, options) {
12
+ const { fileName, width = 1080, height = 1920, format = "png", quality = 1, onError } = options;
13
+ try {
14
+ const tmpUri = await captureRef(ref, { format, quality, width, height });
15
+ const namedUri = `${cacheDirectory}${fileName}`;
16
+ await deleteAsync(namedUri, { idempotent: true });
17
+ await copyAsync({ from: tmpUri, to: namedUri });
18
+ return namedUri;
19
+ } catch (error) {
20
+ onError?.(error);
21
+ return void 0;
22
+ }
23
+ }
24
+
25
+ // src/createSharer.ts
26
+ var DEFAULT_FALLBACK_ID = "system-sheet";
27
+ function createSharer(options) {
28
+ const { channels, onEvent } = options;
29
+ const registry = /* @__PURE__ */ new Map();
30
+ for (const channel of channels) {
31
+ if (registry.has(channel.id)) {
32
+ throw new Error(`[rn-social-share] Duplicate channel id "${channel.id}"`);
33
+ }
34
+ registry.set(channel.id, channel);
35
+ }
36
+ const fallbackId = options.fallbackChannelId ?? DEFAULT_FALLBACK_ID;
37
+ const fallbackChannel = registry.get(fallbackId);
38
+ const emit = (event) => {
39
+ try {
40
+ onEvent?.(event);
41
+ } catch {
42
+ }
43
+ };
44
+ const emitCompleted = (outcome) => {
45
+ emit({
46
+ type: "share_completed",
47
+ channel: outcome.channel,
48
+ fellBack: outcome.fellBack ?? false,
49
+ targetApp: outcome.targetApp
50
+ });
51
+ };
52
+ const runFallback = async (requestedId, payload) => {
53
+ if (!fallbackChannel || fallbackChannel.id === requestedId) {
54
+ return { completed: false, channel: requestedId, fellBack: false };
55
+ }
56
+ try {
57
+ const outcome = await fallbackChannel.share(payload);
58
+ return { ...outcome, channel: fallbackChannel.id, fellBack: true };
59
+ } catch {
60
+ return { completed: false, channel: fallbackChannel.id, fellBack: true };
61
+ }
62
+ };
63
+ const share = async (channelId, payload) => {
64
+ emit({ type: "share_started", channel: channelId, hasImage: Boolean(payload.imageUri) });
65
+ const channel = registry.get(channelId);
66
+ let available = channel !== void 0;
67
+ if (channel) {
68
+ try {
69
+ available = await channel.isAvailable(payload);
70
+ } catch {
71
+ available = false;
72
+ }
73
+ }
74
+ if (!channel || !available) {
75
+ emit({ type: "share_unavailable", channel: channelId });
76
+ const outcome = await runFallback(channelId, payload);
77
+ emitCompleted(outcome);
78
+ return outcome;
79
+ }
80
+ try {
81
+ const outcome = await channel.share(payload);
82
+ emitCompleted(outcome);
83
+ return outcome;
84
+ } catch (error) {
85
+ emit({ type: "share_failed", channel: channelId, error });
86
+ const outcome = await runFallback(channelId, payload);
87
+ emitCompleted(outcome);
88
+ return outcome;
89
+ }
90
+ };
91
+ return {
92
+ channelIds: channels.map((channel) => channel.id),
93
+ has: (channelId) => registry.has(channelId),
94
+ isAvailable: async (channelId, payload) => {
95
+ const channel = registry.get(channelId);
96
+ if (!channel) {
97
+ return false;
98
+ }
99
+ try {
100
+ return await channel.isAvailable(payload);
101
+ } catch {
102
+ return false;
103
+ }
104
+ },
105
+ share
106
+ };
107
+ }
108
+
109
+ // src/channels/_shareSheet.ts
110
+ import Share from "react-native-share";
111
+ async function openSystemShareSheet(payload, channelId) {
112
+ const result = await Share.open({
113
+ message: payload.message,
114
+ url: payload.imageUri,
115
+ type: payload.imageUri ? "image/png" : void 0,
116
+ filename: payload.fileName,
117
+ failOnCancel: false
118
+ });
119
+ if (result?.dismissedAction) {
120
+ return { completed: false, channel: channelId };
121
+ }
122
+ return { completed: true, channel: channelId, targetApp: result?.app ?? null };
123
+ }
124
+
125
+ // src/channels/systemSheet.ts
126
+ var systemSheet = (options = {}) => {
127
+ const id = options.id ?? "system-sheet";
128
+ return {
129
+ id,
130
+ isAvailable: async () => true,
131
+ share: (payload) => openSystemShareSheet(payload, id)
132
+ };
133
+ };
134
+
135
+ // src/channels/whatsapp.ts
136
+ import { Linking } from "react-native";
137
+ var WHATSAPP_PROBE_URL = "whatsapp://send";
138
+ var whatsapp = (options = {}) => {
139
+ const id = options.id ?? "whatsapp";
140
+ return {
141
+ id,
142
+ isAvailable: () => Linking.canOpenURL(WHATSAPP_PROBE_URL).catch(() => false),
143
+ share: (payload) => openSystemShareSheet(payload, id)
144
+ };
145
+ };
146
+
147
+ // src/channels/copyLink.ts
148
+ var copyLink = (options = {}) => {
149
+ const id = options.id ?? "link";
150
+ return {
151
+ id,
152
+ isAvailable: async (payload) => typeof payload.link === "string" && payload.link.length > 0,
153
+ share: async (payload) => {
154
+ if (!payload.link) {
155
+ return { completed: false, channel: id };
156
+ }
157
+ const Clipboard = __require("expo-clipboard");
158
+ await Clipboard.setStringAsync(payload.link);
159
+ return { completed: true, channel: id };
160
+ }
161
+ };
162
+ };
163
+
164
+ // src/channels/instagramStories.ts
165
+ import { Linking as Linking2 } from "react-native";
166
+ import Share2 from "react-native-share";
167
+ var INSTAGRAM_STORIES_URL = "instagram-stories://share";
168
+ var instagramStories = (options) => {
169
+ const id = options.id ?? "instagram";
170
+ return {
171
+ id,
172
+ isAvailable: async (payload) => {
173
+ if (!payload.imageUri) {
174
+ return false;
175
+ }
176
+ return Linking2.canOpenURL(INSTAGRAM_STORIES_URL).catch(() => false);
177
+ },
178
+ share: async (payload) => {
179
+ if (!payload.imageUri) {
180
+ throw new Error("[rn-social-share] instagramStories requires payload.imageUri");
181
+ }
182
+ await Share2.shareSingle({
183
+ social: Share2.Social.INSTAGRAM_STORIES,
184
+ backgroundImage: payload.imageUri,
185
+ appId: options.appId
186
+ });
187
+ return { completed: true, channel: id };
188
+ }
189
+ };
190
+ };
191
+
192
+ // src/channels/sms.ts
193
+ import Share3 from "react-native-share";
194
+ var sms = (options = {}) => {
195
+ const id = options.id ?? "messages";
196
+ return {
197
+ id,
198
+ isAvailable: async () => true,
199
+ share: async (payload) => {
200
+ await Share3.shareSingle({
201
+ social: Share3.Social.SMS,
202
+ message: payload.message,
203
+ url: payload.imageUri
204
+ });
205
+ return { completed: true, channel: id };
206
+ }
207
+ };
208
+ };
209
+
210
+ // src/channels/tiktok.ts
211
+ var tiktok = (options = {}) => {
212
+ const id = options.id ?? "tiktok";
213
+ const fallback = (payload) => openSystemShareSheet(payload, id).then((outcome) => ({ ...outcome, fellBack: true }));
214
+ return {
215
+ id,
216
+ isAvailable: async (payload) => Boolean(payload.imageUri),
217
+ share: async (payload) => {
218
+ if (!payload.imageUri) {
219
+ return fallback(payload);
220
+ }
221
+ try {
222
+ const mod = __require("tiktok-opensdk-react-native");
223
+ const TikTokOpenSDK = mod?.default ?? mod;
224
+ if (TikTokOpenSDK && typeof TikTokOpenSDK.share === "function") {
225
+ TikTokOpenSDK.share([payload.imageUri], true, false).catch((error) => {
226
+ options.onError?.(error);
227
+ void openSystemShareSheet(payload, id);
228
+ });
229
+ return { completed: true, channel: id };
230
+ }
231
+ } catch (error) {
232
+ options.onError?.(error);
233
+ }
234
+ return fallback(payload);
235
+ }
236
+ };
237
+ };
238
+
239
+ // src/channels/saveToLibrary.ts
240
+ var saveToLibrary = (options = {}) => {
241
+ const id = options.id ?? "save";
242
+ return {
243
+ id,
244
+ isAvailable: async (payload) => Boolean(payload.imageUri),
245
+ share: async (payload) => {
246
+ if (!payload.imageUri) {
247
+ throw new Error("[rn-social-share] saveToLibrary requires payload.imageUri");
248
+ }
249
+ const MediaLibrary = __require("expo-media-library");
250
+ const current = await MediaLibrary.getPermissionsAsync(true);
251
+ let granted = current.granted;
252
+ if (!granted) {
253
+ const requested = await MediaLibrary.requestPermissionsAsync(true);
254
+ granted = requested.granted;
255
+ }
256
+ if (!granted) {
257
+ return { completed: false, channel: id, permissionDenied: true };
258
+ }
259
+ await MediaLibrary.saveToLibraryAsync(payload.imageUri);
260
+ return { completed: true, channel: id };
261
+ }
262
+ };
263
+ };
264
+
265
+ // src/react/useShareCard.ts
266
+ import { useCallback, useRef } from "react";
267
+ function useShareCard(options) {
268
+ const ref = useRef(null);
269
+ const { fileName, width, height, format, quality, onError } = options;
270
+ const capture = useCallback(
271
+ () => captureCardImage(ref, { fileName, width, height, format, quality, onError }),
272
+ [fileName, width, height, format, quality, onError]
273
+ );
274
+ return { ref, capture };
275
+ }
276
+
277
+ // src/react/useScreenshotTrigger.ts
278
+ import { useEffect, useRef as useRef2 } from "react";
279
+
280
+ // src/react/screenshotDebounce.ts
281
+ var SCREENSHOT_DEBOUNCE_MS = 1e3;
282
+ function shouldHandleScreenshot(lastHandledAt, now, windowMs = SCREENSHOT_DEBOUNCE_MS) {
283
+ if (lastHandledAt === null) {
284
+ return true;
285
+ }
286
+ return now - lastHandledAt >= windowMs;
287
+ }
288
+ function createScreenshotGate(windowMs = SCREENSHOT_DEBOUNCE_MS) {
289
+ let lastHandledAt = null;
290
+ return function tryHandleScreenshot(now) {
291
+ if (shouldHandleScreenshot(lastHandledAt, now, windowMs)) {
292
+ lastHandledAt = now;
293
+ return true;
294
+ }
295
+ return false;
296
+ };
297
+ }
298
+ var screenshotGate = createScreenshotGate();
299
+
300
+ // src/react/useScreenshotTrigger.ts
301
+ function useScreenshotTrigger(enabled, onScreenshot) {
302
+ const onScreenshotRef = useRef2(onScreenshot);
303
+ onScreenshotRef.current = onScreenshot;
304
+ useEffect(
305
+ function subscribeToScreenshots() {
306
+ if (!enabled) {
307
+ return;
308
+ }
309
+ let subscription;
310
+ try {
311
+ const { addScreenshotListener } = __require("expo-screen-capture");
312
+ subscription = addScreenshotListener(() => {
313
+ if (screenshotGate(Date.now())) {
314
+ onScreenshotRef.current();
315
+ }
316
+ });
317
+ } catch {
318
+ }
319
+ return () => subscription?.remove();
320
+ },
321
+ [enabled]
322
+ );
323
+ }
324
+
325
+ // src/react/imageReadiness.ts
326
+ function areAllImagesLoaded(loaded, required) {
327
+ return required.every((key) => loaded.has(key));
328
+ }
329
+ export {
330
+ SCREENSHOT_DEBOUNCE_MS,
331
+ areAllImagesLoaded,
332
+ captureCardImage,
333
+ copyLink,
334
+ createScreenshotGate,
335
+ createSharer,
336
+ instagramStories,
337
+ openSystemShareSheet,
338
+ saveToLibrary,
339
+ screenshotGate,
340
+ shouldHandleScreenshot,
341
+ sms,
342
+ systemSheet,
343
+ tiktok,
344
+ useScreenshotTrigger,
345
+ useShareCard,
346
+ whatsapp
347
+ };
348
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/capture.ts","../src/createSharer.ts","../src/channels/_shareSheet.ts","../src/channels/systemSheet.ts","../src/channels/whatsapp.ts","../src/channels/copyLink.ts","../src/channels/instagramStories.ts","../src/channels/sms.ts","../src/channels/tiktok.ts","../src/channels/saveToLibrary.ts","../src/react/useShareCard.ts","../src/react/useScreenshotTrigger.ts","../src/react/screenshotDebounce.ts","../src/react/imageReadiness.ts"],"sourcesContent":["import { cacheDirectory, copyAsync, deleteAsync } from \"expo-file-system/legacy\";\nimport type { RefObject } from \"react\";\nimport { captureRef } from \"react-native-view-shot\";\n\nexport type CaptureOptions = {\n /**\n * Branded file name (with extension) the captured image is copied to, so every\n * consumer — the share sheet, an SMS attachment, TikTok, the saved photo — shows a\n * clean name (e.g. \"unchaind-invite.png\") instead of view-shot's random UUID.\n */\n fileName: string;\n /** Output width in px. Default 1080 (story resolution at 3× of a 360pt card). */\n width?: number;\n /** Output height in px. Default 1920. */\n height?: number;\n /** Image format. Default \"png\". */\n format?: \"png\" | \"jpg\";\n /** 0..1. Default 1. */\n quality?: number;\n /** Called with the underlying error if capture fails (the function still resolves undefined). */\n onError?: (error: unknown) => void;\n};\n\n/**\n * Captures an off-screen view (via its ref) to an image file at story resolution, then\n * copies it to a branded file name. Returns the file URI, or `undefined` on failure so\n * callers can fall back to a text-only share.\n *\n * This is the single, deduplicated version of the capture logic both apps had copied\n * verbatim. Extracted as a plain async function so it is testable without rendering.\n *\n * Note (ROC-2883): view-shot must not run until every async image in the card has\n * painted, or a late-loading image is absent from the file. Coordinate readiness with\n * `areAllImagesLoaded` before calling this.\n */\nexport async function captureCardImage(\n ref: RefObject<unknown>,\n options: CaptureOptions,\n): Promise<string | undefined> {\n const { fileName, width = 1080, height = 1920, format = \"png\", quality = 1, onError } = options;\n try {\n // view-shot's temp file is managed and can't be moved reliably — copy it instead.\n const tmpUri = await captureRef(ref as never, { format, quality, width, height });\n const namedUri = `${cacheDirectory}${fileName}`;\n await deleteAsync(namedUri, { idempotent: true });\n await copyAsync({ from: tmpUri, to: namedUri });\n return namedUri;\n } catch (error) {\n onError?.(error);\n return undefined;\n }\n}\n","import type {\n CreateSharerOptions,\n ShareChannel,\n ShareEvent,\n SharePayload,\n ShareOutcome,\n Sharer,\n} from \"./types\";\n\nconst DEFAULT_FALLBACK_ID = \"system-sheet\";\n\n/**\n * Builds a sharer from a list of channels. This is the abstraction layer between the app\n * and react-native-share / the OpenSDKs: the app only ever calls `sharer.share(id, payload)`\n * and adds/removes channels by editing the `channels` array.\n *\n * Routing per share:\n * 1. Emit `share_started`.\n * 2. Unknown id, or `isAvailable()` false/throws → `share_unavailable`, route to fallback.\n * 3. `share()` throws → `share_failed`, route to fallback.\n * 4. Emit `share_completed` with the channel that actually handled it.\n *\n * The fallback (default: the channel registered as \"system-sheet\") is never used to route\n * back into itself, so a missing/failed fallback yields a non-completed outcome instead of\n * looping.\n */\nexport function createSharer(options: CreateSharerOptions): Sharer {\n const { channels, onEvent } = options;\n\n const registry = new Map<string, ShareChannel>();\n for (const channel of channels) {\n if (registry.has(channel.id)) {\n throw new Error(`[rn-social-share] Duplicate channel id \"${channel.id}\"`);\n }\n registry.set(channel.id, channel);\n }\n\n const fallbackId = options.fallbackChannelId ?? DEFAULT_FALLBACK_ID;\n const fallbackChannel = registry.get(fallbackId);\n\n const emit = (event: ShareEvent): void => {\n try {\n onEvent?.(event);\n } catch {\n // Analytics/logging must never break a share.\n }\n };\n\n const emitCompleted = (outcome: ShareOutcome): void => {\n emit({\n type: \"share_completed\",\n channel: outcome.channel,\n fellBack: outcome.fellBack ?? false,\n targetApp: outcome.targetApp,\n });\n };\n\n const runFallback = async (requestedId: string, payload: SharePayload): Promise<ShareOutcome> => {\n if (!fallbackChannel || fallbackChannel.id === requestedId) {\n return { completed: false, channel: requestedId, fellBack: false };\n }\n try {\n const outcome = await fallbackChannel.share(payload);\n return { ...outcome, channel: fallbackChannel.id, fellBack: true };\n } catch {\n return { completed: false, channel: fallbackChannel.id, fellBack: true };\n }\n };\n\n const share = async (channelId: string, payload: SharePayload): Promise<ShareOutcome> => {\n emit({ type: \"share_started\", channel: channelId, hasImage: Boolean(payload.imageUri) });\n\n const channel = registry.get(channelId);\n\n let available = channel !== undefined;\n if (channel) {\n try {\n available = await channel.isAvailable(payload);\n } catch {\n available = false;\n }\n }\n\n if (!channel || !available) {\n emit({ type: \"share_unavailable\", channel: channelId });\n const outcome = await runFallback(channelId, payload);\n emitCompleted(outcome);\n return outcome;\n }\n\n try {\n const outcome = await channel.share(payload);\n emitCompleted(outcome);\n return outcome;\n } catch (error) {\n emit({ type: \"share_failed\", channel: channelId, error });\n const outcome = await runFallback(channelId, payload);\n emitCompleted(outcome);\n return outcome;\n }\n };\n\n return {\n channelIds: channels.map((channel) => channel.id),\n has: (channelId) => registry.has(channelId),\n isAvailable: async (channelId, payload) => {\n const channel = registry.get(channelId);\n if (!channel) {\n return false;\n }\n try {\n return await channel.isAvailable(payload);\n } catch {\n return false;\n }\n },\n share,\n };\n}\n","import Share from \"react-native-share\";\nimport type { ShareOutcome, SharePayload } from \"../types\";\n\ntype ShareOpenResult = { dismissedAction?: boolean; app?: string | null };\n\n/**\n * Opens the native OS share sheet with the image + caption + branded file name. Shared by\n * the `systemSheet` and `whatsapp` channels and used as the TikTok fallback. With\n * `failOnCancel: false`, a user cancel resolves (with `dismissedAction`) instead of throwing.\n */\nexport async function openSystemShareSheet(\n payload: SharePayload,\n channelId: string,\n): Promise<ShareOutcome> {\n const result = (await Share.open({\n message: payload.message,\n url: payload.imageUri,\n type: payload.imageUri ? \"image/png\" : undefined,\n filename: payload.fileName,\n failOnCancel: false,\n })) as ShareOpenResult;\n\n if (result?.dismissedAction) {\n return { completed: false, channel: channelId };\n }\n return { completed: true, channel: channelId, targetApp: result?.app ?? null };\n}\n","import type { ShareChannel } from \"../types\";\nimport { openSystemShareSheet } from \"./_shareSheet\";\n\n/**\n * The generic native share sheet. Always available, so it doubles as the default fallback\n * (register it with the default id \"system-sheet\" to have `createSharer` pick it up).\n */\nexport const systemSheet = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"system-sheet\";\n return {\n id,\n isAvailable: async () => true,\n share: (payload) => openSystemShareSheet(payload, id),\n };\n};\n","import { Linking } from \"react-native\";\nimport type { ShareChannel } from \"../types\";\nimport { openSystemShareSheet } from \"./_shareSheet\";\n\nconst WHATSAPP_PROBE_URL = \"whatsapp://send\";\n\n/**\n * WhatsApp — intentionally routed through the system share sheet. iOS has no reliable\n * direct deep link that carries the full text AND an image: both `whatsapp://send?text=`\n * and `wa.me` drop everything but the URL on real devices, and RNShare's WhatsApp-text\n * path builds an invalid URL (literal spaces). The share sheet's WhatsApp extension is the\n * only dependable path. `isAvailable` still probes for the app so the UI can hide the\n * button when WhatsApp isn't installed.\n *\n * iOS: requires `whatsapp` in LSApplicationQueriesSchemes (see `withSocialShare`).\n */\nexport const whatsapp = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"whatsapp\";\n return {\n id,\n isAvailable: () => Linking.canOpenURL(WHATSAPP_PROBE_URL).catch(() => false),\n share: (payload) => openSystemShareSheet(payload, id),\n };\n};\n","import type { ShareChannel } from \"../types\";\n\n/**\n * Copies `payload.link` to the clipboard. Not a \"share\" in the OS sense, but modeled as a\n * channel so it slots into the same UI row and analytics funnel as everything else.\n *\n * `expo-clipboard` is a lazy require so importing the channel barrel never forces the\n * dependency on apps that don't use this channel.\n */\nexport const copyLink = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"link\";\n return {\n id,\n isAvailable: async (payload) => typeof payload.link === \"string\" && payload.link.length > 0,\n share: async (payload) => {\n if (!payload.link) {\n return { completed: false, channel: id };\n }\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const Clipboard = require(\"expo-clipboard\");\n await Clipboard.setStringAsync(payload.link);\n return { completed: true, channel: id };\n },\n };\n};\n","import { Linking } from \"react-native\";\nimport Share, { type ShareSingleOptions } from \"react-native-share\";\nimport type { ShareChannel } from \"../types\";\n\nconst INSTAGRAM_STORIES_URL = \"instagram-stories://share\";\n\n/**\n * Instagram Stories with the captured image as the full-bleed background.\n *\n * `appId` is required and app-specific — it is the Facebook/Meta App ID tied to your app\n * (mandatory for the Stories deep link since Jan 2023). Pass your own; the two apps that\n * seeded this package use different ids.\n *\n * iOS: requires `instagram-stories` in LSApplicationQueriesSchemes (see `withSocialShare`)\n * for `isAvailable` to detect Instagram. When unavailable, `createSharer` routes to the\n * fallback channel.\n */\nexport const instagramStories = (options: { appId: string; id?: string }): ShareChannel => {\n const id = options.id ?? \"instagram\";\n return {\n id,\n isAvailable: async (payload) => {\n if (!payload.imageUri) {\n return false;\n }\n return Linking.canOpenURL(INSTAGRAM_STORIES_URL).catch(() => false);\n },\n share: async (payload) => {\n if (!payload.imageUri) {\n throw new Error(\"[rn-social-share] instagramStories requires payload.imageUri\");\n }\n await Share.shareSingle({\n social: Share.Social.INSTAGRAM_STORIES,\n backgroundImage: payload.imageUri,\n appId: options.appId,\n } as ShareSingleOptions);\n return { completed: true, channel: id };\n },\n };\n};\n","import Share, { type ShareSingleOptions } from \"react-native-share\";\nimport type { ShareChannel } from \"../types\";\n\n/**\n * Native SMS/Messages composer with the full text body and the card image attached.\n * `isAvailable` is optimistic (true); a device without SMS makes `shareSingle` throw, and\n * `createSharer` then routes to the fallback channel.\n */\nexport const sms = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"messages\";\n return {\n id,\n isAvailable: async () => true,\n share: async (payload) => {\n await Share.shareSingle({\n social: Share.Social.SMS,\n message: payload.message,\n url: payload.imageUri,\n } as ShareSingleOptions);\n return { completed: true, channel: id };\n },\n };\n};\n","import type { ShareChannel } from \"../types\";\nimport { openSystemShareSheet } from \"./_shareSheet\";\n\nexport type TikTokChannelOptions = {\n id?: string;\n /** Called if the OpenSDK is missing or rejects (before falling back to the system sheet). */\n onError?: (error: unknown) => void;\n};\n\n/**\n * TikTok via `tiktok-opensdk-react-native`, fire-and-forget. Opening TikTok IS the action —\n * we never await the SDK's result promise because, with no AppDelegate callback wired, it\n * may never settle. We report `completed` optimistically once the share is kicked off; a\n * *real* rejection (e.g. TikTok not installed) still routes to the system sheet so the user\n * can share.\n *\n * The SDK is a lazy require and optional: if it isn't installed, this channel simply falls\n * back to the system sheet. Native setup (client key, FileProvider, package visibility)\n * lives in the app's own TikTok config plugin — see the README.\n *\n * API (tiktok-opensdk-react-native@0.10.x): default export\n * TikTokOpenSDK.share(mediaPaths: string[], isImage?: boolean, isGreenScreen?: boolean)\n */\nexport const tiktok = (options: TikTokChannelOptions = {}): ShareChannel => {\n const id = options.id ?? \"tiktok\";\n\n const fallback = (payload: Parameters<ShareChannel[\"share\"]>[0]) =>\n openSystemShareSheet(payload, id).then((outcome) => ({ ...outcome, fellBack: true }));\n\n return {\n id,\n isAvailable: async (payload) => Boolean(payload.imageUri),\n share: async (payload) => {\n if (!payload.imageUri) {\n return fallback(payload);\n }\n try {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const mod = require(\"tiktok-opensdk-react-native\");\n const TikTokOpenSDK = mod?.default ?? mod;\n if (TikTokOpenSDK && typeof TikTokOpenSDK.share === \"function\") {\n // Fire-and-forget. Fall back only on a real rejection; the promise-never-settles\n // case simply never runs `.catch`, which is the intended behavior.\n TikTokOpenSDK.share([payload.imageUri], true, false).catch((error: unknown) => {\n options.onError?.(error);\n void openSystemShareSheet(payload, id);\n });\n return { completed: true, channel: id };\n }\n } catch (error) {\n options.onError?.(error);\n }\n return fallback(payload);\n },\n };\n};\n","import type { ShareChannel } from \"../types\";\n\n/**\n * Saves the image to the device photo library via `expo-media-library` (add-only / write\n * permission, which avoids needing a full photo-library read description on iOS). Returns\n * `{ completed: false, permissionDenied: true }` when the user denies access, so the host UI\n * can show an \"open settings\" prompt.\n *\n * `expo-media-library` is a lazy require and optional — only apps that register this channel\n * need it installed.\n */\nexport const saveToLibrary = (options: { id?: string } = {}): ShareChannel => {\n const id = options.id ?? \"save\";\n return {\n id,\n isAvailable: async (payload) => Boolean(payload.imageUri),\n share: async (payload) => {\n if (!payload.imageUri) {\n throw new Error(\"[rn-social-share] saveToLibrary requires payload.imageUri\");\n }\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const MediaLibrary = require(\"expo-media-library\");\n const current = await MediaLibrary.getPermissionsAsync(true);\n let granted = current.granted;\n if (!granted) {\n const requested = await MediaLibrary.requestPermissionsAsync(true);\n granted = requested.granted;\n }\n if (!granted) {\n return { completed: false, channel: id, permissionDenied: true };\n }\n await MediaLibrary.saveToLibraryAsync(payload.imageUri);\n return { completed: true, channel: id };\n },\n };\n};\n","import { useCallback, useRef } from \"react\";\nimport type { View } from \"react-native\";\nimport { captureCardImage, type CaptureOptions } from \"../capture\";\n\n/**\n * Headless capture hook: gives you a `ref` to attach to your off-screen card view and a\n * `capture()` that renders it to a branded image file (or `undefined` on failure).\n *\n * The card visual itself stays in the app — the package only captures whatever you point\n * the ref at. Render the card off-screen (e.g. `position: \"absolute\", left: -10000`) and\n * gate `capture()` on `areAllImagesLoaded` if the card has async images.\n *\n * Need two variants (e.g. rounded for the sheet, square full-bleed for Stories)? Call this\n * hook twice, or call `captureCardImage` directly with each ref.\n */\nexport function useShareCard(options: CaptureOptions) {\n const ref = useRef<View>(null);\n const { fileName, width, height, format, quality, onError } = options;\n\n const capture = useCallback(\n () => captureCardImage(ref, { fileName, width, height, format, quality, onError }),\n [fileName, width, height, format, quality, onError],\n );\n\n return { ref, capture };\n}\n","import { useEffect, useRef } from \"react\";\nimport { screenshotGate } from \"./screenshotDebounce\";\n\n/**\n * Calls `onScreenshot` when the user takes a screenshot, while `enabled` is true — the\n * \"share on screenshot\" trigger from the Expo Marathon pattern. Dedup is enforced by a\n * shared, app-wide gate so one screenshot fires at most one handler even when iOS\n * double-fires or several instances are mounted (ROC-2854).\n *\n * No-ops silently where screenshot detection is unavailable (iOS supported; Android only\n * API 34+) or when `expo-screen-capture` isn't installed — the lazy require keeps that\n * dependency optional, and an explicit share button remains the fallback.\n */\nexport function useScreenshotTrigger(enabled: boolean, onScreenshot: () => void): void {\n const onScreenshotRef = useRef(onScreenshot);\n onScreenshotRef.current = onScreenshot;\n\n useEffect(\n function subscribeToScreenshots() {\n if (!enabled) {\n return;\n }\n let subscription: { remove: () => void } | undefined;\n try {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const { addScreenshotListener } = require(\"expo-screen-capture\");\n subscription = addScreenshotListener(() => {\n if (screenshotGate(Date.now())) {\n onScreenshotRef.current();\n }\n });\n } catch {\n // Screenshot detection unavailable / module not installed — silent fail.\n }\n return () => subscription?.remove();\n },\n [enabled],\n );\n}\n","export const SCREENSHOT_DEBOUNCE_MS = 1000;\n\nexport function shouldHandleScreenshot(\n lastHandledAt: number | null,\n now: number,\n windowMs: number = SCREENSHOT_DEBOUNCE_MS,\n): boolean {\n if (lastHandledAt === null) {\n return true;\n }\n return now - lastHandledAt >= windowMs;\n}\n\n/**\n * A screenshot is a single OS-wide event, but one screenshot can reach a handler more than\n * once: iOS double-fires the notification, and release builds can mount more than one\n * `useScreenshotTrigger` instance (ROC-2854). A per-instance debounce can't dedupe across\n * instances. This gate holds the last-handled timestamp so that, no matter how many callers\n * react to the same screenshot, `gate(now)` returns `true` at most once per `windowMs`.\n *\n * The hook uses a single shared gate (`screenshotGate`); the factory exists so tests (and\n * apps that want isolation) get their own instance.\n */\nexport function createScreenshotGate(windowMs: number = SCREENSHOT_DEBOUNCE_MS) {\n let lastHandledAt: number | null = null;\n return function tryHandleScreenshot(now: number): boolean {\n if (shouldHandleScreenshot(lastHandledAt, now, windowMs)) {\n lastHandledAt = now;\n return true;\n }\n return false;\n };\n}\n\n/** Single app-wide screenshot gate shared by every `useScreenshotTrigger`. */\nexport const screenshotGate = createScreenshotGate();\n","/**\n * A shareable card often contains several asynchronously-loaded images (a fruit\n * illustration, a logo…). view-shot must not capture until every one has painted, or an\n * image that loads after the capture fires is absent from the file. This was ROC-2883: the\n * capture only awaited one image, so the logo was frequently missing from the export.\n *\n * Track loaded keys in a Set as each `<Image onLoad>` fires, then gate the capture on\n * `areAllImagesLoaded(loaded, REQUIRED_KEYS)`.\n */\nexport function areAllImagesLoaded(\n loaded: ReadonlySet<string>,\n required: readonly string[],\n): boolean {\n return required.every((key) => loaded.has(key));\n}\n"],"mappings":"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","names":["Linking","Share","Share","useRef","useRef"]}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@rocapine/rn-social-share",
3
+ "version": "0.1.0",
4
+ "description": "Headless social-sharing engine for React Native / Expo apps: capture a view as an image and share it across modular channels (Instagram Stories, TikTok, WhatsApp, SMS, system sheet, save to library, copy link).",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Rocapine/rn-social-share.git"
9
+ },
10
+ "main": "dist/index.js",
11
+ "module": "dist/index.mjs",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.mjs",
17
+ "require": "./dist/index.js"
18
+ },
19
+ "./app.plugin.js": "./app.plugin.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src",
24
+ "app.plugin.js"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup",
31
+ "clean": "rm -rf dist",
32
+ "type": "tsc --noEmit",
33
+ "test": "jest",
34
+ "prepack": "npm run build"
35
+ },
36
+ "peerDependencies": {
37
+ "react": "*",
38
+ "react-native": "*",
39
+ "react-native-share": ">=12",
40
+ "react-native-view-shot": ">=4",
41
+ "expo-file-system": "*",
42
+ "expo-clipboard": "*",
43
+ "expo-media-library": "*",
44
+ "expo-screen-capture": "*",
45
+ "tiktok-opensdk-react-native": "*",
46
+ "@expo/config-plugins": "*"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "expo-clipboard": {
50
+ "optional": true
51
+ },
52
+ "expo-media-library": {
53
+ "optional": true
54
+ },
55
+ "expo-screen-capture": {
56
+ "optional": true
57
+ },
58
+ "tiktok-opensdk-react-native": {
59
+ "optional": true
60
+ },
61
+ "@expo/config-plugins": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "devDependencies": {
66
+ "@types/jest": "^29.5.12",
67
+ "@types/react": "*",
68
+ "jest": "^29.7.0",
69
+ "ts-jest": "^29.1.2",
70
+ "tsup": "^8.3.5",
71
+ "typescript": "^5.4.0"
72
+ }
73
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { cacheDirectory, copyAsync, deleteAsync } from "expo-file-system/legacy";
2
+ import type { RefObject } from "react";
3
+ import { captureRef } from "react-native-view-shot";
4
+
5
+ export type CaptureOptions = {
6
+ /**
7
+ * Branded file name (with extension) the captured image is copied to, so every
8
+ * consumer — the share sheet, an SMS attachment, TikTok, the saved photo — shows a
9
+ * clean name (e.g. "unchaind-invite.png") instead of view-shot's random UUID.
10
+ */
11
+ fileName: string;
12
+ /** Output width in px. Default 1080 (story resolution at 3× of a 360pt card). */
13
+ width?: number;
14
+ /** Output height in px. Default 1920. */
15
+ height?: number;
16
+ /** Image format. Default "png". */
17
+ format?: "png" | "jpg";
18
+ /** 0..1. Default 1. */
19
+ quality?: number;
20
+ /** Called with the underlying error if capture fails (the function still resolves undefined). */
21
+ onError?: (error: unknown) => void;
22
+ };
23
+
24
+ /**
25
+ * Captures an off-screen view (via its ref) to an image file at story resolution, then
26
+ * copies it to a branded file name. Returns the file URI, or `undefined` on failure so
27
+ * callers can fall back to a text-only share.
28
+ *
29
+ * This is the single, deduplicated version of the capture logic both apps had copied
30
+ * verbatim. Extracted as a plain async function so it is testable without rendering.
31
+ *
32
+ * Note (ROC-2883): view-shot must not run until every async image in the card has
33
+ * painted, or a late-loading image is absent from the file. Coordinate readiness with
34
+ * `areAllImagesLoaded` before calling this.
35
+ */
36
+ export async function captureCardImage(
37
+ ref: RefObject<unknown>,
38
+ options: CaptureOptions,
39
+ ): Promise<string | undefined> {
40
+ const { fileName, width = 1080, height = 1920, format = "png", quality = 1, onError } = options;
41
+ try {
42
+ // view-shot's temp file is managed and can't be moved reliably — copy it instead.
43
+ const tmpUri = await captureRef(ref as never, { format, quality, width, height });
44
+ const namedUri = `${cacheDirectory}${fileName}`;
45
+ await deleteAsync(namedUri, { idempotent: true });
46
+ await copyAsync({ from: tmpUri, to: namedUri });
47
+ return namedUri;
48
+ } catch (error) {
49
+ onError?.(error);
50
+ return undefined;
51
+ }
52
+ }
@@ -0,0 +1,27 @@
1
+ import Share from "react-native-share";
2
+ import type { ShareOutcome, SharePayload } from "../types";
3
+
4
+ type ShareOpenResult = { dismissedAction?: boolean; app?: string | null };
5
+
6
+ /**
7
+ * Opens the native OS share sheet with the image + caption + branded file name. Shared by
8
+ * the `systemSheet` and `whatsapp` channels and used as the TikTok fallback. With
9
+ * `failOnCancel: false`, a user cancel resolves (with `dismissedAction`) instead of throwing.
10
+ */
11
+ export async function openSystemShareSheet(
12
+ payload: SharePayload,
13
+ channelId: string,
14
+ ): Promise<ShareOutcome> {
15
+ const result = (await Share.open({
16
+ message: payload.message,
17
+ url: payload.imageUri,
18
+ type: payload.imageUri ? "image/png" : undefined,
19
+ filename: payload.fileName,
20
+ failOnCancel: false,
21
+ })) as ShareOpenResult;
22
+
23
+ if (result?.dismissedAction) {
24
+ return { completed: false, channel: channelId };
25
+ }
26
+ return { completed: true, channel: channelId, targetApp: result?.app ?? null };
27
+ }
@@ -0,0 +1,25 @@
1
+ import type { ShareChannel } from "../types";
2
+
3
+ /**
4
+ * Copies `payload.link` to the clipboard. Not a "share" in the OS sense, but modeled as a
5
+ * channel so it slots into the same UI row and analytics funnel as everything else.
6
+ *
7
+ * `expo-clipboard` is a lazy require so importing the channel barrel never forces the
8
+ * dependency on apps that don't use this channel.
9
+ */
10
+ export const copyLink = (options: { id?: string } = {}): ShareChannel => {
11
+ const id = options.id ?? "link";
12
+ return {
13
+ id,
14
+ isAvailable: async (payload) => typeof payload.link === "string" && payload.link.length > 0,
15
+ share: async (payload) => {
16
+ if (!payload.link) {
17
+ return { completed: false, channel: id };
18
+ }
19
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
20
+ const Clipboard = require("expo-clipboard");
21
+ await Clipboard.setStringAsync(payload.link);
22
+ return { completed: true, channel: id };
23
+ },
24
+ };
25
+ };