@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,8 @@
1
+ export { systemSheet } from "./systemSheet";
2
+ export { whatsapp } from "./whatsapp";
3
+ export { copyLink } from "./copyLink";
4
+ export { instagramStories } from "./instagramStories";
5
+ export { sms } from "./sms";
6
+ export { tiktok, type TikTokChannelOptions } from "./tiktok";
7
+ export { saveToLibrary } from "./saveToLibrary";
8
+ export { openSystemShareSheet } from "./_shareSheet";
@@ -0,0 +1,40 @@
1
+ import { Linking } from "react-native";
2
+ import Share, { type ShareSingleOptions } from "react-native-share";
3
+ import type { ShareChannel } from "../types";
4
+
5
+ const INSTAGRAM_STORIES_URL = "instagram-stories://share";
6
+
7
+ /**
8
+ * Instagram Stories with the captured image as the full-bleed background.
9
+ *
10
+ * `appId` is required and app-specific — it is the Facebook/Meta App ID tied to your app
11
+ * (mandatory for the Stories deep link since Jan 2023). Pass your own; the two apps that
12
+ * seeded this package use different ids.
13
+ *
14
+ * iOS: requires `instagram-stories` in LSApplicationQueriesSchemes (see `withSocialShare`)
15
+ * for `isAvailable` to detect Instagram. When unavailable, `createSharer` routes to the
16
+ * fallback channel.
17
+ */
18
+ export const instagramStories = (options: { appId: string; id?: string }): ShareChannel => {
19
+ const id = options.id ?? "instagram";
20
+ return {
21
+ id,
22
+ isAvailable: async (payload) => {
23
+ if (!payload.imageUri) {
24
+ return false;
25
+ }
26
+ return Linking.canOpenURL(INSTAGRAM_STORIES_URL).catch(() => false);
27
+ },
28
+ share: async (payload) => {
29
+ if (!payload.imageUri) {
30
+ throw new Error("[rn-social-share] instagramStories requires payload.imageUri");
31
+ }
32
+ await Share.shareSingle({
33
+ social: Share.Social.INSTAGRAM_STORIES,
34
+ backgroundImage: payload.imageUri,
35
+ appId: options.appId,
36
+ } as ShareSingleOptions);
37
+ return { completed: true, channel: id };
38
+ },
39
+ };
40
+ };
@@ -0,0 +1,36 @@
1
+ import type { ShareChannel } from "../types";
2
+
3
+ /**
4
+ * Saves the image to the device photo library via `expo-media-library` (add-only / write
5
+ * permission, which avoids needing a full photo-library read description on iOS). Returns
6
+ * `{ completed: false, permissionDenied: true }` when the user denies access, so the host UI
7
+ * can show an "open settings" prompt.
8
+ *
9
+ * `expo-media-library` is a lazy require and optional — only apps that register this channel
10
+ * need it installed.
11
+ */
12
+ export const saveToLibrary = (options: { id?: string } = {}): ShareChannel => {
13
+ const id = options.id ?? "save";
14
+ return {
15
+ id,
16
+ isAvailable: async (payload) => Boolean(payload.imageUri),
17
+ share: async (payload) => {
18
+ if (!payload.imageUri) {
19
+ throw new Error("[rn-social-share] saveToLibrary requires payload.imageUri");
20
+ }
21
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
22
+ const MediaLibrary = require("expo-media-library");
23
+ const current = await MediaLibrary.getPermissionsAsync(true);
24
+ let granted = current.granted;
25
+ if (!granted) {
26
+ const requested = await MediaLibrary.requestPermissionsAsync(true);
27
+ granted = requested.granted;
28
+ }
29
+ if (!granted) {
30
+ return { completed: false, channel: id, permissionDenied: true };
31
+ }
32
+ await MediaLibrary.saveToLibraryAsync(payload.imageUri);
33
+ return { completed: true, channel: id };
34
+ },
35
+ };
36
+ };
@@ -0,0 +1,23 @@
1
+ import Share, { type ShareSingleOptions } from "react-native-share";
2
+ import type { ShareChannel } from "../types";
3
+
4
+ /**
5
+ * Native SMS/Messages composer with the full text body and the card image attached.
6
+ * `isAvailable` is optimistic (true); a device without SMS makes `shareSingle` throw, and
7
+ * `createSharer` then routes to the fallback channel.
8
+ */
9
+ export const sms = (options: { id?: string } = {}): ShareChannel => {
10
+ const id = options.id ?? "messages";
11
+ return {
12
+ id,
13
+ isAvailable: async () => true,
14
+ share: async (payload) => {
15
+ await Share.shareSingle({
16
+ social: Share.Social.SMS,
17
+ message: payload.message,
18
+ url: payload.imageUri,
19
+ } as ShareSingleOptions);
20
+ return { completed: true, channel: id };
21
+ },
22
+ };
23
+ };
@@ -0,0 +1,15 @@
1
+ import type { ShareChannel } from "../types";
2
+ import { openSystemShareSheet } from "./_shareSheet";
3
+
4
+ /**
5
+ * The generic native share sheet. Always available, so it doubles as the default fallback
6
+ * (register it with the default id "system-sheet" to have `createSharer` pick it up).
7
+ */
8
+ export const systemSheet = (options: { id?: string } = {}): ShareChannel => {
9
+ const id = options.id ?? "system-sheet";
10
+ return {
11
+ id,
12
+ isAvailable: async () => true,
13
+ share: (payload) => openSystemShareSheet(payload, id),
14
+ };
15
+ };
@@ -0,0 +1,56 @@
1
+ import type { ShareChannel } from "../types";
2
+ import { openSystemShareSheet } from "./_shareSheet";
3
+
4
+ export type TikTokChannelOptions = {
5
+ id?: string;
6
+ /** Called if the OpenSDK is missing or rejects (before falling back to the system sheet). */
7
+ onError?: (error: unknown) => void;
8
+ };
9
+
10
+ /**
11
+ * TikTok via `tiktok-opensdk-react-native`, fire-and-forget. Opening TikTok IS the action —
12
+ * we never await the SDK's result promise because, with no AppDelegate callback wired, it
13
+ * may never settle. We report `completed` optimistically once the share is kicked off; a
14
+ * *real* rejection (e.g. TikTok not installed) still routes to the system sheet so the user
15
+ * can share.
16
+ *
17
+ * The SDK is a lazy require and optional: if it isn't installed, this channel simply falls
18
+ * back to the system sheet. Native setup (client key, FileProvider, package visibility)
19
+ * lives in the app's own TikTok config plugin — see the README.
20
+ *
21
+ * API (tiktok-opensdk-react-native@0.10.x): default export
22
+ * TikTokOpenSDK.share(mediaPaths: string[], isImage?: boolean, isGreenScreen?: boolean)
23
+ */
24
+ export const tiktok = (options: TikTokChannelOptions = {}): ShareChannel => {
25
+ const id = options.id ?? "tiktok";
26
+
27
+ const fallback = (payload: Parameters<ShareChannel["share"]>[0]) =>
28
+ openSystemShareSheet(payload, id).then((outcome) => ({ ...outcome, fellBack: true }));
29
+
30
+ return {
31
+ id,
32
+ isAvailable: async (payload) => Boolean(payload.imageUri),
33
+ share: async (payload) => {
34
+ if (!payload.imageUri) {
35
+ return fallback(payload);
36
+ }
37
+ try {
38
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
39
+ const mod = require("tiktok-opensdk-react-native");
40
+ const TikTokOpenSDK = mod?.default ?? mod;
41
+ if (TikTokOpenSDK && typeof TikTokOpenSDK.share === "function") {
42
+ // Fire-and-forget. Fall back only on a real rejection; the promise-never-settles
43
+ // case simply never runs `.catch`, which is the intended behavior.
44
+ TikTokOpenSDK.share([payload.imageUri], true, false).catch((error: unknown) => {
45
+ options.onError?.(error);
46
+ void openSystemShareSheet(payload, id);
47
+ });
48
+ return { completed: true, channel: id };
49
+ }
50
+ } catch (error) {
51
+ options.onError?.(error);
52
+ }
53
+ return fallback(payload);
54
+ },
55
+ };
56
+ };
@@ -0,0 +1,24 @@
1
+ import { Linking } from "react-native";
2
+ import type { ShareChannel } from "../types";
3
+ import { openSystemShareSheet } from "./_shareSheet";
4
+
5
+ const WHATSAPP_PROBE_URL = "whatsapp://send";
6
+
7
+ /**
8
+ * WhatsApp — intentionally routed through the system share sheet. iOS has no reliable
9
+ * direct deep link that carries the full text AND an image: both `whatsapp://send?text=`
10
+ * and `wa.me` drop everything but the URL on real devices, and RNShare's WhatsApp-text
11
+ * path builds an invalid URL (literal spaces). The share sheet's WhatsApp extension is the
12
+ * only dependable path. `isAvailable` still probes for the app so the UI can hide the
13
+ * button when WhatsApp isn't installed.
14
+ *
15
+ * iOS: requires `whatsapp` in LSApplicationQueriesSchemes (see `withSocialShare`).
16
+ */
17
+ export const whatsapp = (options: { id?: string } = {}): ShareChannel => {
18
+ const id = options.id ?? "whatsapp";
19
+ return {
20
+ id,
21
+ isAvailable: () => Linking.canOpenURL(WHATSAPP_PROBE_URL).catch(() => false),
22
+ share: (payload) => openSystemShareSheet(payload, id),
23
+ };
24
+ };
@@ -0,0 +1,119 @@
1
+ import type {
2
+ CreateSharerOptions,
3
+ ShareChannel,
4
+ ShareEvent,
5
+ SharePayload,
6
+ ShareOutcome,
7
+ Sharer,
8
+ } from "./types";
9
+
10
+ const DEFAULT_FALLBACK_ID = "system-sheet";
11
+
12
+ /**
13
+ * Builds a sharer from a list of channels. This is the abstraction layer between the app
14
+ * and react-native-share / the OpenSDKs: the app only ever calls `sharer.share(id, payload)`
15
+ * and adds/removes channels by editing the `channels` array.
16
+ *
17
+ * Routing per share:
18
+ * 1. Emit `share_started`.
19
+ * 2. Unknown id, or `isAvailable()` false/throws → `share_unavailable`, route to fallback.
20
+ * 3. `share()` throws → `share_failed`, route to fallback.
21
+ * 4. Emit `share_completed` with the channel that actually handled it.
22
+ *
23
+ * The fallback (default: the channel registered as "system-sheet") is never used to route
24
+ * back into itself, so a missing/failed fallback yields a non-completed outcome instead of
25
+ * looping.
26
+ */
27
+ export function createSharer(options: CreateSharerOptions): Sharer {
28
+ const { channels, onEvent } = options;
29
+
30
+ const registry = new Map<string, ShareChannel>();
31
+ for (const channel of channels) {
32
+ if (registry.has(channel.id)) {
33
+ throw new Error(`[rn-social-share] Duplicate channel id "${channel.id}"`);
34
+ }
35
+ registry.set(channel.id, channel);
36
+ }
37
+
38
+ const fallbackId = options.fallbackChannelId ?? DEFAULT_FALLBACK_ID;
39
+ const fallbackChannel = registry.get(fallbackId);
40
+
41
+ const emit = (event: ShareEvent): void => {
42
+ try {
43
+ onEvent?.(event);
44
+ } catch {
45
+ // Analytics/logging must never break a share.
46
+ }
47
+ };
48
+
49
+ const emitCompleted = (outcome: ShareOutcome): void => {
50
+ emit({
51
+ type: "share_completed",
52
+ channel: outcome.channel,
53
+ fellBack: outcome.fellBack ?? false,
54
+ targetApp: outcome.targetApp,
55
+ });
56
+ };
57
+
58
+ const runFallback = async (requestedId: string, payload: SharePayload): Promise<ShareOutcome> => {
59
+ if (!fallbackChannel || fallbackChannel.id === requestedId) {
60
+ return { completed: false, channel: requestedId, fellBack: false };
61
+ }
62
+ try {
63
+ const outcome = await fallbackChannel.share(payload);
64
+ return { ...outcome, channel: fallbackChannel.id, fellBack: true };
65
+ } catch {
66
+ return { completed: false, channel: fallbackChannel.id, fellBack: true };
67
+ }
68
+ };
69
+
70
+ const share = async (channelId: string, payload: SharePayload): Promise<ShareOutcome> => {
71
+ emit({ type: "share_started", channel: channelId, hasImage: Boolean(payload.imageUri) });
72
+
73
+ const channel = registry.get(channelId);
74
+
75
+ let available = channel !== undefined;
76
+ if (channel) {
77
+ try {
78
+ available = await channel.isAvailable(payload);
79
+ } catch {
80
+ available = false;
81
+ }
82
+ }
83
+
84
+ if (!channel || !available) {
85
+ emit({ type: "share_unavailable", channel: channelId });
86
+ const outcome = await runFallback(channelId, payload);
87
+ emitCompleted(outcome);
88
+ return outcome;
89
+ }
90
+
91
+ try {
92
+ const outcome = await channel.share(payload);
93
+ emitCompleted(outcome);
94
+ return outcome;
95
+ } catch (error) {
96
+ emit({ type: "share_failed", channel: channelId, error });
97
+ const outcome = await runFallback(channelId, payload);
98
+ emitCompleted(outcome);
99
+ return outcome;
100
+ }
101
+ };
102
+
103
+ return {
104
+ channelIds: channels.map((channel) => channel.id),
105
+ has: (channelId) => registry.has(channelId),
106
+ isAvailable: async (channelId, payload) => {
107
+ const channel = registry.get(channelId);
108
+ if (!channel) {
109
+ return false;
110
+ }
111
+ try {
112
+ return await channel.isAvailable(payload);
113
+ } catch {
114
+ return false;
115
+ }
116
+ },
117
+ share,
118
+ };
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Core (pure, no UI)
2
+ export * from "./types";
3
+ export { captureCardImage, type CaptureOptions } from "./capture";
4
+ export { createSharer } from "./createSharer";
5
+
6
+ // Channels (modular registry)
7
+ export * from "./channels";
8
+
9
+ // React layer (optional hooks + helpers)
10
+ export * from "./react";
@@ -0,0 +1,68 @@
1
+ const { withInfoPlist, withAndroidManifest } = require("@expo/config-plugins");
2
+
3
+ // The URL schemes a channel needs in the iOS Info.plist LSApplicationQueriesSchemes so
4
+ // `Linking.canOpenURL()` (used by the channels' `isAvailable`) returns true instead of
5
+ // always false.
6
+ const IOS_QUERY_SCHEMES = {
7
+ instagram: ["instagram-stories", "instagram"],
8
+ whatsapp: ["whatsapp"],
9
+ tiktok: ["tiktoksharesdk", "snssdk1233", "musically"],
10
+ };
11
+
12
+ // The Android package names a channel needs in <queries> for package visibility
13
+ // (Android 11+), so app detection / share targeting works.
14
+ const ANDROID_PACKAGES = {
15
+ instagram: ["com.instagram.android"],
16
+ whatsapp: ["com.whatsapp"],
17
+ tiktok: ["com.zhiliaoapp.musically", "com.ss.android.ugc.trill"],
18
+ };
19
+
20
+ /**
21
+ * Expo config plugin that declares the app-query permissions the enabled share channels
22
+ * need, so channel availability detection works on both platforms.
23
+ *
24
+ * Usage in app.config.ts / app.json:
25
+ * ["@rocapine/rn-social-share", { "channels": ["instagram", "whatsapp", "tiktok"] }]
26
+ *
27
+ * Scope note: this covers the query/visibility entries only. TikTok's full native wiring
28
+ * (client key in Info.plist / strings.xml, meta-data, and the FileProvider) is heavier and
29
+ * currently lives in the app's own `withTikTokShare` plugin; folding it in here is a
30
+ * follow-up once this package is validated with a prebuild.
31
+ */
32
+ const withSocialShare = (config, options = {}) => {
33
+ const channels = Array.isArray(options.channels) ? options.channels : [];
34
+
35
+ config = withInfoPlist(config, (cfg) => {
36
+ const existing = cfg.modResults.LSApplicationQueriesSchemes || [];
37
+ const schemes = new Set(existing);
38
+ for (const channel of channels) {
39
+ for (const scheme of IOS_QUERY_SCHEMES[channel] || []) {
40
+ schemes.add(scheme);
41
+ }
42
+ }
43
+ cfg.modResults.LSApplicationQueriesSchemes = Array.from(schemes);
44
+ return cfg;
45
+ });
46
+
47
+ config = withAndroidManifest(config, (cfg) => {
48
+ const manifest = cfg.modResults.manifest;
49
+ manifest.queries = manifest.queries || [{}];
50
+ const query = manifest.queries[0];
51
+ query.package = query.package || [];
52
+ const seen = new Set(query.package.map((p) => p.$ && p.$["android:name"]));
53
+ for (const channel of channels) {
54
+ for (const pkg of ANDROID_PACKAGES[channel] || []) {
55
+ if (!seen.has(pkg)) {
56
+ query.package.push({ $: { "android:name": pkg } });
57
+ seen.add(pkg);
58
+ }
59
+ }
60
+ }
61
+ return cfg;
62
+ });
63
+
64
+ return config;
65
+ };
66
+
67
+ module.exports = withSocialShare;
68
+ module.exports.default = withSocialShare;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A shareable card often contains several asynchronously-loaded images (a fruit
3
+ * illustration, a logo…). view-shot must not capture until every one has painted, or an
4
+ * image that loads after the capture fires is absent from the file. This was ROC-2883: the
5
+ * capture only awaited one image, so the logo was frequently missing from the export.
6
+ *
7
+ * Track loaded keys in a Set as each `<Image onLoad>` fires, then gate the capture on
8
+ * `areAllImagesLoaded(loaded, REQUIRED_KEYS)`.
9
+ */
10
+ export function areAllImagesLoaded(
11
+ loaded: ReadonlySet<string>,
12
+ required: readonly string[],
13
+ ): boolean {
14
+ return required.every((key) => loaded.has(key));
15
+ }
@@ -0,0 +1,9 @@
1
+ export { useShareCard } from "./useShareCard";
2
+ export { useScreenshotTrigger } from "./useScreenshotTrigger";
3
+ export { areAllImagesLoaded } from "./imageReadiness";
4
+ export {
5
+ SCREENSHOT_DEBOUNCE_MS,
6
+ shouldHandleScreenshot,
7
+ createScreenshotGate,
8
+ screenshotGate,
9
+ } from "./screenshotDebounce";
@@ -0,0 +1,36 @@
1
+ export const SCREENSHOT_DEBOUNCE_MS = 1000;
2
+
3
+ export function shouldHandleScreenshot(
4
+ lastHandledAt: number | null,
5
+ now: number,
6
+ windowMs: number = SCREENSHOT_DEBOUNCE_MS,
7
+ ): boolean {
8
+ if (lastHandledAt === null) {
9
+ return true;
10
+ }
11
+ return now - lastHandledAt >= windowMs;
12
+ }
13
+
14
+ /**
15
+ * A screenshot is a single OS-wide event, but one screenshot can reach a handler more than
16
+ * once: iOS double-fires the notification, and release builds can mount more than one
17
+ * `useScreenshotTrigger` instance (ROC-2854). A per-instance debounce can't dedupe across
18
+ * instances. This gate holds the last-handled timestamp so that, no matter how many callers
19
+ * react to the same screenshot, `gate(now)` returns `true` at most once per `windowMs`.
20
+ *
21
+ * The hook uses a single shared gate (`screenshotGate`); the factory exists so tests (and
22
+ * apps that want isolation) get their own instance.
23
+ */
24
+ export function createScreenshotGate(windowMs: number = SCREENSHOT_DEBOUNCE_MS) {
25
+ let lastHandledAt: number | null = null;
26
+ return function tryHandleScreenshot(now: number): boolean {
27
+ if (shouldHandleScreenshot(lastHandledAt, now, windowMs)) {
28
+ lastHandledAt = now;
29
+ return true;
30
+ }
31
+ return false;
32
+ };
33
+ }
34
+
35
+ /** Single app-wide screenshot gate shared by every `useScreenshotTrigger`. */
36
+ export const screenshotGate = createScreenshotGate();
@@ -0,0 +1,39 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { screenshotGate } from "./screenshotDebounce";
3
+
4
+ /**
5
+ * Calls `onScreenshot` when the user takes a screenshot, while `enabled` is true — the
6
+ * "share on screenshot" trigger from the Expo Marathon pattern. Dedup is enforced by a
7
+ * shared, app-wide gate so one screenshot fires at most one handler even when iOS
8
+ * double-fires or several instances are mounted (ROC-2854).
9
+ *
10
+ * No-ops silently where screenshot detection is unavailable (iOS supported; Android only
11
+ * API 34+) or when `expo-screen-capture` isn't installed — the lazy require keeps that
12
+ * dependency optional, and an explicit share button remains the fallback.
13
+ */
14
+ export function useScreenshotTrigger(enabled: boolean, onScreenshot: () => void): void {
15
+ const onScreenshotRef = useRef(onScreenshot);
16
+ onScreenshotRef.current = onScreenshot;
17
+
18
+ useEffect(
19
+ function subscribeToScreenshots() {
20
+ if (!enabled) {
21
+ return;
22
+ }
23
+ let subscription: { remove: () => void } | undefined;
24
+ try {
25
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
26
+ const { addScreenshotListener } = require("expo-screen-capture");
27
+ subscription = addScreenshotListener(() => {
28
+ if (screenshotGate(Date.now())) {
29
+ onScreenshotRef.current();
30
+ }
31
+ });
32
+ } catch {
33
+ // Screenshot detection unavailable / module not installed — silent fail.
34
+ }
35
+ return () => subscription?.remove();
36
+ },
37
+ [enabled],
38
+ );
39
+ }
@@ -0,0 +1,26 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { View } from "react-native";
3
+ import { captureCardImage, type CaptureOptions } from "../capture";
4
+
5
+ /**
6
+ * Headless capture hook: gives you a `ref` to attach to your off-screen card view and a
7
+ * `capture()` that renders it to a branded image file (or `undefined` on failure).
8
+ *
9
+ * The card visual itself stays in the app — the package only captures whatever you point
10
+ * the ref at. Render the card off-screen (e.g. `position: "absolute", left: -10000`) and
11
+ * gate `capture()` on `areAllImagesLoaded` if the card has async images.
12
+ *
13
+ * Need two variants (e.g. rounded for the sheet, square full-bleed for Stories)? Call this
14
+ * hook twice, or call `captureCardImage` directly with each ref.
15
+ */
16
+ export function useShareCard(options: CaptureOptions) {
17
+ const ref = useRef<View>(null);
18
+ const { fileName, width, height, format, quality, onError } = options;
19
+
20
+ const capture = useCallback(
21
+ () => captureCardImage(ref, { fileName, width, height, format, quality, onError }),
22
+ [fileName, width, height, format, quality, onError],
23
+ );
24
+
25
+ return { ref, capture };
26
+ }
package/src/types.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The data a share can carry. Every field is optional so a channel can pick what it
3
+ * understands: `copyLink` only needs `link`, `instagramStories` only needs `imageUri`,
4
+ * the system sheet uses all of them.
5
+ */
6
+ export type SharePayload = {
7
+ /** Local file URI of the image to share (PNG). Produced by `captureCardImage`. */
8
+ imageUri?: string;
9
+ /** Text caption / body. */
10
+ message?: string;
11
+ /** A URL to share or copy. */
12
+ link?: string;
13
+ /** File name (no extension needed) the OS share sheet shows for the attachment. */
14
+ fileName?: string;
15
+ };
16
+
17
+ /** Result of a share attempt. Best-effort: some channels cannot know if the user completed. */
18
+ export type ShareOutcome = {
19
+ /** Whether the flow was completed as far as we can tell (false = dismissed/denied). */
20
+ completed: boolean;
21
+ /** The channel id that ultimately handled the share (may differ from the requested one). */
22
+ channel: string;
23
+ /** True when the requested channel was unavailable/failed and we routed to the fallback. */
24
+ fellBack?: boolean;
25
+ /** For the system sheet: which app the OS reported handling the share, if any. */
26
+ targetApp?: string | null;
27
+ /** For `saveToLibrary`: the OS denied photo-library permission. */
28
+ permissionDenied?: boolean;
29
+ };
30
+
31
+ /**
32
+ * A single share destination. Implement this to add a new channel — that is the whole
33
+ * extension surface. Register instances with `createSharer({ channels: [...] })`.
34
+ */
35
+ export type ShareChannel = {
36
+ /** Stable identifier, e.g. "instagram", "tiktok", "system-sheet". */
37
+ readonly id: string;
38
+ /** Can this channel currently handle the payload (app installed, image present…)? */
39
+ isAvailable(payload: SharePayload): Promise<boolean>;
40
+ /** Perform the share. Resolve with the outcome; throw only on unexpected errors. */
41
+ share(payload: SharePayload): Promise<ShareOutcome>;
42
+ };
43
+
44
+ /** Events emitted by the sharer so the host app can wire its own analytics/logging. */
45
+ export type ShareEvent =
46
+ | { type: "share_started"; channel: string; hasImage: boolean }
47
+ | { type: "share_completed"; channel: string; fellBack: boolean; targetApp?: string | null }
48
+ | { type: "share_unavailable"; channel: string }
49
+ | { type: "share_failed"; channel: string; error: unknown };
50
+
51
+ export type CreateSharerOptions = {
52
+ /** Ordered list of channels. Order is preserved in `channelIds` for building UIs. */
53
+ channels: ShareChannel[];
54
+ /**
55
+ * Channel id used when a requested channel is unavailable or throws.
56
+ * Defaults to "system-sheet" when a channel with that id is registered.
57
+ */
58
+ fallbackChannelId?: string;
59
+ /** Analytics / logging sink. Fire-and-forget — thrown errors here never break a share. */
60
+ onEvent?: (event: ShareEvent) => void;
61
+ };
62
+
63
+ export type Sharer = {
64
+ /** Registered channel ids in registration order (handy for rendering a channel row). */
65
+ readonly channelIds: string[];
66
+ has(channelId: string): boolean;
67
+ isAvailable(channelId: string, payload: SharePayload): Promise<boolean>;
68
+ share(channelId: string, payload: SharePayload): Promise<ShareOutcome>;
69
+ };