@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.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # @rocapine/rn-social-share
2
+
3
+ A **headless** social-sharing engine for React Native / Expo apps. It captures a view as an
4
+ image and shares it across **modular channels** (Instagram Stories, TikTok, WhatsApp, SMS,
5
+ the system share sheet, save-to-library, copy-link), behind a small abstraction so the app
6
+ never touches `react-native-share` / the OpenSDKs directly.
7
+
8
+ Extracted from the sharing features in **Eve** (weekly baby card) and **Unchaind** (invite
9
+ card). This is the shared _plumbing_ — the visual card, the UI chrome, analytics wiring and
10
+ i18n stay in each app and are injected.
11
+
12
+ ## What's in the box
13
+
14
+ ```
15
+ src/
16
+ types.ts SharePayload · ShareOutcome · ShareChannel · ShareEvent · Sharer
17
+ capture.ts captureCardImage(ref, opts) — view-shot → branded PNG
18
+ createSharer.ts createSharer({channels, onEvent, fallbackChannelId})
19
+ channels/ systemSheet · whatsapp · copyLink · instagramStories · sms · tiktok · saveToLibrary
20
+ react/ useShareCard · useScreenshotTrigger · areAllImagesLoaded · screenshot gate
21
+ plugin/ withSocialShare — iOS LSApplicationQueriesSchemes + Android <queries>
22
+ ```
23
+
24
+ **Design principle — headless first.** The package owns capture, channel routing, screenshot
25
+ detection and native query-permissions. It ships **no UI**: the two apps' share surfaces are
26
+ very different (a Tamagui bottom sheet vs. a two-theme custom hub), so UI stays in the app.
27
+
28
+ ## Install (peer deps)
29
+
30
+ Required in the host app:
31
+
32
+ ```
33
+ react-native-share react-native-view-shot expo-file-system
34
+ ```
35
+
36
+ Optional — only needed if you register the matching channel/hook:
37
+
38
+ | Dependency | Needed by |
39
+ | ----------------------------- | ------------------------------ |
40
+ | `expo-clipboard` | `copyLink` |
41
+ | `expo-media-library` | `saveToLibrary` |
42
+ | `expo-screen-capture` | `useScreenshotTrigger` |
43
+ | `tiktok-opensdk-react-native` | `tiktok` |
44
+ | `@expo/config-plugins` | `withSocialShare` (build-time) |
45
+
46
+ Optional channels lazy-`require` their native module, so importing the barrel never forces a
47
+ dependency you don't use.
48
+
49
+ ## Usage
50
+
51
+ ### 1. Build a sharer (once, near your feature)
52
+
53
+ ```ts
54
+ import {
55
+ createSharer,
56
+ systemSheet,
57
+ copyLink,
58
+ sms,
59
+ whatsapp,
60
+ instagramStories,
61
+ tiktok,
62
+ } from "@rocapine/rn-social-share";
63
+
64
+ export const inviteSharer = createSharer({
65
+ channels: [
66
+ copyLink(),
67
+ sms(),
68
+ whatsapp(),
69
+ instagramStories({ appId: FACEBOOK_APP_ID }),
70
+ tiktok({ onError: (e) => logger.warn("tiktok", e) }),
71
+ systemSheet(), // default fallback (id "system-sheet")
72
+ ],
73
+ onEvent: (e) => {
74
+ if (e.type === "share_completed") {
75
+ analytics.logEvent("invite_link_shared", {
76
+ channel: e.channel,
77
+ fell_back: e.fellBack,
78
+ });
79
+ }
80
+ },
81
+ });
82
+ ```
83
+
84
+ Add or remove a channel = edit the array. That's the whole extension surface.
85
+
86
+ ### 2. Capture the card and share
87
+
88
+ ```tsx
89
+ import { useShareCard } from "@rocapine/rn-social-share";
90
+
91
+ const { ref, capture } = useShareCard({ fileName: "unchaind-invite.png" });
92
+
93
+ const onChannel = async (channelId: string) => {
94
+ const imageUri = channelId === "link" ? undefined : await capture();
95
+ await inviteSharer.share(channelId, {
96
+ message,
97
+ link,
98
+ imageUri,
99
+ fileName: "unchaind-invite",
100
+ });
101
+ };
102
+
103
+ // Render the card off-screen; point the ref at it:
104
+ <View style={{ position: "absolute", left: -10000 }} pointerEvents="none">
105
+ <YourShareCard ref={ref} inviteCode={code} />
106
+ </View>;
107
+ ```
108
+
109
+ Need a rounded variant for the sheet and a square full-bleed one for Stories? Call
110
+ `useShareCard` twice, or call `captureCardImage(ref, opts)` directly with each ref.
111
+
112
+ ### 3. Share on screenshot (optional, the Expo Marathon pattern)
113
+
114
+ ```ts
115
+ import { useScreenshotTrigger } from "@rocapine/rn-social-share";
116
+
117
+ useScreenshotTrigger(isVisible && !isSheetOpen, () =>
118
+ openShareSheet("screenshot"),
119
+ );
120
+ ```
121
+
122
+ A shared, app-wide gate dedupes the iOS double-fire / multiple-listener case (ROC-2854).
123
+
124
+ ### 4. Wire native query-permissions (build time)
125
+
126
+ ```jsonc
127
+ // app.json / app.config.ts plugins
128
+ [
129
+ "@rocapine/rn-social-share",
130
+ { "channels": ["instagram", "whatsapp", "tiktok"] },
131
+ ]
132
+ ```
133
+
134
+ Adds the iOS `LSApplicationQueriesSchemes` and Android `<queries>` entries the channels'
135
+ `isAvailable` checks need. **Scope:** query/visibility only — TikTok's full native wiring
136
+ (client key, FileProvider) still lives in the app's `withTikTokShare` plugin for now.
137
+
138
+ ## Authoring a channel
139
+
140
+ ```ts
141
+ import type { ShareChannel } from "@rocapine/rn-social-share";
142
+
143
+ export const threads = (opts: { id?: string } = {}): ShareChannel => {
144
+ const id = opts.id ?? "threads";
145
+ return {
146
+ id,
147
+ isAvailable: async (p) => Boolean(p.imageUri),
148
+ share: async (p) => {
149
+ /* ...open Threads... */
150
+ return { completed: true, channel: id };
151
+ },
152
+ };
153
+ };
154
+ ```
155
+
156
+ `createSharer` handles routing, availability checks, fallback and events around it.
157
+
158
+ ## Consuming this package
159
+
160
+ Consumed as a **git dependency pinned to a tag** (no registry). In the host app's
161
+ `package.json`:
162
+
163
+ ```jsonc
164
+ "@rocapine/rn-social-share": "github:Rocapine/rn-social-share#v0.1.0"
165
+ ```
166
+
167
+ The built `dist/` (CJS + ESM + `.d.ts`) is committed, so installs work with any package
168
+ manager without an install-time build step. Then declare the peer deps you actually use and
169
+ add the config plugin (see above).
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ npm install
175
+ npm run type # tsc --noEmit
176
+ npm test # jest
177
+ npm run build # tsup → dist/ (CJS + ESM + d.ts)
178
+ ```
179
+
180
+ CI (GitHub Actions) runs type-check + tests + build on every push/PR and fails if the
181
+ committed `dist/` is stale. **Rebuild and commit `dist/` before tagging a release.**
182
+
183
+ ## Status & next steps
184
+
185
+ - [x] Core: types, capture, `createSharer`, channels, React hooks, config plugin
186
+ - [x] Tests for `createSharer`, the screenshot gate, and channel translation (24 passing)
187
+ - [x] Build (tsup) + type-check + CI, published as `v0.1.0`
188
+ - [ ] Migrate Unchaind's `components/invite/` onto the package (superset of channels)
189
+ - [ ] Migrate Eve's `features/Sharing/` (adds `saveToLibrary` + screenshot trigger usage)
190
+ - [ ] Fold TikTok's full native config into `withSocialShare`
package/app.plugin.js ADDED
@@ -0,0 +1,2 @@
1
+ // Expo resolves a package's config plugin from `app.plugin.js` at the package root.
2
+ module.exports = require("./src/plugin/withSocialShare");
@@ -0,0 +1,285 @@
1
+ import * as react from 'react';
2
+ import { RefObject } from 'react';
3
+ import { View } from 'react-native';
4
+
5
+ /**
6
+ * The data a share can carry. Every field is optional so a channel can pick what it
7
+ * understands: `copyLink` only needs `link`, `instagramStories` only needs `imageUri`,
8
+ * the system sheet uses all of them.
9
+ */
10
+ type SharePayload = {
11
+ /** Local file URI of the image to share (PNG). Produced by `captureCardImage`. */
12
+ imageUri?: string;
13
+ /** Text caption / body. */
14
+ message?: string;
15
+ /** A URL to share or copy. */
16
+ link?: string;
17
+ /** File name (no extension needed) the OS share sheet shows for the attachment. */
18
+ fileName?: string;
19
+ };
20
+ /** Result of a share attempt. Best-effort: some channels cannot know if the user completed. */
21
+ type ShareOutcome = {
22
+ /** Whether the flow was completed as far as we can tell (false = dismissed/denied). */
23
+ completed: boolean;
24
+ /** The channel id that ultimately handled the share (may differ from the requested one). */
25
+ channel: string;
26
+ /** True when the requested channel was unavailable/failed and we routed to the fallback. */
27
+ fellBack?: boolean;
28
+ /** For the system sheet: which app the OS reported handling the share, if any. */
29
+ targetApp?: string | null;
30
+ /** For `saveToLibrary`: the OS denied photo-library permission. */
31
+ permissionDenied?: boolean;
32
+ };
33
+ /**
34
+ * A single share destination. Implement this to add a new channel — that is the whole
35
+ * extension surface. Register instances with `createSharer({ channels: [...] })`.
36
+ */
37
+ type ShareChannel = {
38
+ /** Stable identifier, e.g. "instagram", "tiktok", "system-sheet". */
39
+ readonly id: string;
40
+ /** Can this channel currently handle the payload (app installed, image present…)? */
41
+ isAvailable(payload: SharePayload): Promise<boolean>;
42
+ /** Perform the share. Resolve with the outcome; throw only on unexpected errors. */
43
+ share(payload: SharePayload): Promise<ShareOutcome>;
44
+ };
45
+ /** Events emitted by the sharer so the host app can wire its own analytics/logging. */
46
+ type ShareEvent = {
47
+ type: "share_started";
48
+ channel: string;
49
+ hasImage: boolean;
50
+ } | {
51
+ type: "share_completed";
52
+ channel: string;
53
+ fellBack: boolean;
54
+ targetApp?: string | null;
55
+ } | {
56
+ type: "share_unavailable";
57
+ channel: string;
58
+ } | {
59
+ type: "share_failed";
60
+ channel: string;
61
+ error: unknown;
62
+ };
63
+ type CreateSharerOptions = {
64
+ /** Ordered list of channels. Order is preserved in `channelIds` for building UIs. */
65
+ channels: ShareChannel[];
66
+ /**
67
+ * Channel id used when a requested channel is unavailable or throws.
68
+ * Defaults to "system-sheet" when a channel with that id is registered.
69
+ */
70
+ fallbackChannelId?: string;
71
+ /** Analytics / logging sink. Fire-and-forget — thrown errors here never break a share. */
72
+ onEvent?: (event: ShareEvent) => void;
73
+ };
74
+ type Sharer = {
75
+ /** Registered channel ids in registration order (handy for rendering a channel row). */
76
+ readonly channelIds: string[];
77
+ has(channelId: string): boolean;
78
+ isAvailable(channelId: string, payload: SharePayload): Promise<boolean>;
79
+ share(channelId: string, payload: SharePayload): Promise<ShareOutcome>;
80
+ };
81
+
82
+ type CaptureOptions = {
83
+ /**
84
+ * Branded file name (with extension) the captured image is copied to, so every
85
+ * consumer — the share sheet, an SMS attachment, TikTok, the saved photo — shows a
86
+ * clean name (e.g. "unchaind-invite.png") instead of view-shot's random UUID.
87
+ */
88
+ fileName: string;
89
+ /** Output width in px. Default 1080 (story resolution at 3× of a 360pt card). */
90
+ width?: number;
91
+ /** Output height in px. Default 1920. */
92
+ height?: number;
93
+ /** Image format. Default "png". */
94
+ format?: "png" | "jpg";
95
+ /** 0..1. Default 1. */
96
+ quality?: number;
97
+ /** Called with the underlying error if capture fails (the function still resolves undefined). */
98
+ onError?: (error: unknown) => void;
99
+ };
100
+ /**
101
+ * Captures an off-screen view (via its ref) to an image file at story resolution, then
102
+ * copies it to a branded file name. Returns the file URI, or `undefined` on failure so
103
+ * callers can fall back to a text-only share.
104
+ *
105
+ * This is the single, deduplicated version of the capture logic both apps had copied
106
+ * verbatim. Extracted as a plain async function so it is testable without rendering.
107
+ *
108
+ * Note (ROC-2883): view-shot must not run until every async image in the card has
109
+ * painted, or a late-loading image is absent from the file. Coordinate readiness with
110
+ * `areAllImagesLoaded` before calling this.
111
+ */
112
+ declare function captureCardImage(ref: RefObject<unknown>, options: CaptureOptions): Promise<string | undefined>;
113
+
114
+ /**
115
+ * Builds a sharer from a list of channels. This is the abstraction layer between the app
116
+ * and react-native-share / the OpenSDKs: the app only ever calls `sharer.share(id, payload)`
117
+ * and adds/removes channels by editing the `channels` array.
118
+ *
119
+ * Routing per share:
120
+ * 1. Emit `share_started`.
121
+ * 2. Unknown id, or `isAvailable()` false/throws → `share_unavailable`, route to fallback.
122
+ * 3. `share()` throws → `share_failed`, route to fallback.
123
+ * 4. Emit `share_completed` with the channel that actually handled it.
124
+ *
125
+ * The fallback (default: the channel registered as "system-sheet") is never used to route
126
+ * back into itself, so a missing/failed fallback yields a non-completed outcome instead of
127
+ * looping.
128
+ */
129
+ declare function createSharer(options: CreateSharerOptions): Sharer;
130
+
131
+ /**
132
+ * The generic native share sheet. Always available, so it doubles as the default fallback
133
+ * (register it with the default id "system-sheet" to have `createSharer` pick it up).
134
+ */
135
+ declare const systemSheet: (options?: {
136
+ id?: string;
137
+ }) => ShareChannel;
138
+
139
+ /**
140
+ * WhatsApp — intentionally routed through the system share sheet. iOS has no reliable
141
+ * direct deep link that carries the full text AND an image: both `whatsapp://send?text=`
142
+ * and `wa.me` drop everything but the URL on real devices, and RNShare's WhatsApp-text
143
+ * path builds an invalid URL (literal spaces). The share sheet's WhatsApp extension is the
144
+ * only dependable path. `isAvailable` still probes for the app so the UI can hide the
145
+ * button when WhatsApp isn't installed.
146
+ *
147
+ * iOS: requires `whatsapp` in LSApplicationQueriesSchemes (see `withSocialShare`).
148
+ */
149
+ declare const whatsapp: (options?: {
150
+ id?: string;
151
+ }) => ShareChannel;
152
+
153
+ /**
154
+ * Copies `payload.link` to the clipboard. Not a "share" in the OS sense, but modeled as a
155
+ * channel so it slots into the same UI row and analytics funnel as everything else.
156
+ *
157
+ * `expo-clipboard` is a lazy require so importing the channel barrel never forces the
158
+ * dependency on apps that don't use this channel.
159
+ */
160
+ declare const copyLink: (options?: {
161
+ id?: string;
162
+ }) => ShareChannel;
163
+
164
+ /**
165
+ * Instagram Stories with the captured image as the full-bleed background.
166
+ *
167
+ * `appId` is required and app-specific — it is the Facebook/Meta App ID tied to your app
168
+ * (mandatory for the Stories deep link since Jan 2023). Pass your own; the two apps that
169
+ * seeded this package use different ids.
170
+ *
171
+ * iOS: requires `instagram-stories` in LSApplicationQueriesSchemes (see `withSocialShare`)
172
+ * for `isAvailable` to detect Instagram. When unavailable, `createSharer` routes to the
173
+ * fallback channel.
174
+ */
175
+ declare const instagramStories: (options: {
176
+ appId: string;
177
+ id?: string;
178
+ }) => ShareChannel;
179
+
180
+ /**
181
+ * Native SMS/Messages composer with the full text body and the card image attached.
182
+ * `isAvailable` is optimistic (true); a device without SMS makes `shareSingle` throw, and
183
+ * `createSharer` then routes to the fallback channel.
184
+ */
185
+ declare const sms: (options?: {
186
+ id?: string;
187
+ }) => ShareChannel;
188
+
189
+ type TikTokChannelOptions = {
190
+ id?: string;
191
+ /** Called if the OpenSDK is missing or rejects (before falling back to the system sheet). */
192
+ onError?: (error: unknown) => void;
193
+ };
194
+ /**
195
+ * TikTok via `tiktok-opensdk-react-native`, fire-and-forget. Opening TikTok IS the action —
196
+ * we never await the SDK's result promise because, with no AppDelegate callback wired, it
197
+ * may never settle. We report `completed` optimistically once the share is kicked off; a
198
+ * *real* rejection (e.g. TikTok not installed) still routes to the system sheet so the user
199
+ * can share.
200
+ *
201
+ * The SDK is a lazy require and optional: if it isn't installed, this channel simply falls
202
+ * back to the system sheet. Native setup (client key, FileProvider, package visibility)
203
+ * lives in the app's own TikTok config plugin — see the README.
204
+ *
205
+ * API (tiktok-opensdk-react-native@0.10.x): default export
206
+ * TikTokOpenSDK.share(mediaPaths: string[], isImage?: boolean, isGreenScreen?: boolean)
207
+ */
208
+ declare const tiktok: (options?: TikTokChannelOptions) => ShareChannel;
209
+
210
+ /**
211
+ * Saves the image to the device photo library via `expo-media-library` (add-only / write
212
+ * permission, which avoids needing a full photo-library read description on iOS). Returns
213
+ * `{ completed: false, permissionDenied: true }` when the user denies access, so the host UI
214
+ * can show an "open settings" prompt.
215
+ *
216
+ * `expo-media-library` is a lazy require and optional — only apps that register this channel
217
+ * need it installed.
218
+ */
219
+ declare const saveToLibrary: (options?: {
220
+ id?: string;
221
+ }) => ShareChannel;
222
+
223
+ /**
224
+ * Opens the native OS share sheet with the image + caption + branded file name. Shared by
225
+ * the `systemSheet` and `whatsapp` channels and used as the TikTok fallback. With
226
+ * `failOnCancel: false`, a user cancel resolves (with `dismissedAction`) instead of throwing.
227
+ */
228
+ declare function openSystemShareSheet(payload: SharePayload, channelId: string): Promise<ShareOutcome>;
229
+
230
+ /**
231
+ * Headless capture hook: gives you a `ref` to attach to your off-screen card view and a
232
+ * `capture()` that renders it to a branded image file (or `undefined` on failure).
233
+ *
234
+ * The card visual itself stays in the app — the package only captures whatever you point
235
+ * the ref at. Render the card off-screen (e.g. `position: "absolute", left: -10000`) and
236
+ * gate `capture()` on `areAllImagesLoaded` if the card has async images.
237
+ *
238
+ * Need two variants (e.g. rounded for the sheet, square full-bleed for Stories)? Call this
239
+ * hook twice, or call `captureCardImage` directly with each ref.
240
+ */
241
+ declare function useShareCard(options: CaptureOptions): {
242
+ ref: react.RefObject<View | null>;
243
+ capture: () => Promise<string | undefined>;
244
+ };
245
+
246
+ /**
247
+ * Calls `onScreenshot` when the user takes a screenshot, while `enabled` is true — the
248
+ * "share on screenshot" trigger from the Expo Marathon pattern. Dedup is enforced by a
249
+ * shared, app-wide gate so one screenshot fires at most one handler even when iOS
250
+ * double-fires or several instances are mounted (ROC-2854).
251
+ *
252
+ * No-ops silently where screenshot detection is unavailable (iOS supported; Android only
253
+ * API 34+) or when `expo-screen-capture` isn't installed — the lazy require keeps that
254
+ * dependency optional, and an explicit share button remains the fallback.
255
+ */
256
+ declare function useScreenshotTrigger(enabled: boolean, onScreenshot: () => void): void;
257
+
258
+ /**
259
+ * A shareable card often contains several asynchronously-loaded images (a fruit
260
+ * illustration, a logo…). view-shot must not capture until every one has painted, or an
261
+ * image that loads after the capture fires is absent from the file. This was ROC-2883: the
262
+ * capture only awaited one image, so the logo was frequently missing from the export.
263
+ *
264
+ * Track loaded keys in a Set as each `<Image onLoad>` fires, then gate the capture on
265
+ * `areAllImagesLoaded(loaded, REQUIRED_KEYS)`.
266
+ */
267
+ declare function areAllImagesLoaded(loaded: ReadonlySet<string>, required: readonly string[]): boolean;
268
+
269
+ declare const SCREENSHOT_DEBOUNCE_MS = 1000;
270
+ declare function shouldHandleScreenshot(lastHandledAt: number | null, now: number, windowMs?: number): boolean;
271
+ /**
272
+ * A screenshot is a single OS-wide event, but one screenshot can reach a handler more than
273
+ * once: iOS double-fires the notification, and release builds can mount more than one
274
+ * `useScreenshotTrigger` instance (ROC-2854). A per-instance debounce can't dedupe across
275
+ * instances. This gate holds the last-handled timestamp so that, no matter how many callers
276
+ * react to the same screenshot, `gate(now)` returns `true` at most once per `windowMs`.
277
+ *
278
+ * The hook uses a single shared gate (`screenshotGate`); the factory exists so tests (and
279
+ * apps that want isolation) get their own instance.
280
+ */
281
+ declare function createScreenshotGate(windowMs?: number): (now: number) => boolean;
282
+ /** Single app-wide screenshot gate shared by every `useScreenshotTrigger`. */
283
+ declare const screenshotGate: (now: number) => boolean;
284
+
285
+ export { type CaptureOptions, type CreateSharerOptions, SCREENSHOT_DEBOUNCE_MS, type ShareChannel, type ShareEvent, type ShareOutcome, type SharePayload, type Sharer, type TikTokChannelOptions, areAllImagesLoaded, captureCardImage, copyLink, createScreenshotGate, createSharer, instagramStories, openSystemShareSheet, saveToLibrary, screenshotGate, shouldHandleScreenshot, sms, systemSheet, tiktok, useScreenshotTrigger, useShareCard, whatsapp };