@sendoracloud/sdk-react-native 0.15.1 → 0.16.1
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 +117 -78
- package/dist/index.cjs +453 -37
- package/dist/index.d.cts +176 -56
- package/dist/index.d.ts +176 -56
- package/dist/index.js +449 -36
- package/examples/links-router.tsx +79 -0
- package/examples/links-share.tsx +99 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -802,44 +802,293 @@ function decodeJwtPayload(token) {
|
|
|
802
802
|
}
|
|
803
803
|
|
|
804
804
|
// src/links.ts
|
|
805
|
+
var LinkError = class extends Error {
|
|
806
|
+
constructor(code, message, statusCode = 0, details) {
|
|
807
|
+
super(message);
|
|
808
|
+
this.name = "LinkError";
|
|
809
|
+
this.code = code;
|
|
810
|
+
this.statusCode = statusCode;
|
|
811
|
+
this.details = details;
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
function mapToLinkError(status, body, fallback) {
|
|
815
|
+
const code = body?.error?.code ?? void 0;
|
|
816
|
+
const msg = body?.error?.message ?? fallback;
|
|
817
|
+
if (status === 0) return new LinkError("NETWORK", msg || "Network unavailable", 0);
|
|
818
|
+
if (status === 401) return new LinkError("UNAUTHORIZED", msg, 401);
|
|
819
|
+
if (status === 403) {
|
|
820
|
+
return new LinkError("UNAUTHORIZED", msg, 403);
|
|
821
|
+
}
|
|
822
|
+
if (status === 404) return new LinkError("NOT_FOUND", msg, 404);
|
|
823
|
+
if (status === 410) return new LinkError("EXPIRED", msg, 410);
|
|
824
|
+
if (status === 429) return new LinkError("RATE_LIMITED", msg, 429, body?.error?.details);
|
|
825
|
+
if (status === 412) return new LinkError("INVALID_INPUT", msg, 412);
|
|
826
|
+
if (status === 422) {
|
|
827
|
+
if (/iOS bundle|Android package/i.test(msg)) return new LinkError("BUNDLE_MISMATCH", msg, 422);
|
|
828
|
+
if (/2KB|10KB|linkData/i.test(msg)) return new LinkError("DATA_TOO_LARGE", msg, 422);
|
|
829
|
+
if (/fallbackUrl/i.test(msg) && /apps/i.test(msg)) return new LinkError("FALLBACK_REQUIRED", msg, 422);
|
|
830
|
+
return new LinkError("INVALID_INPUT", msg, 422);
|
|
831
|
+
}
|
|
832
|
+
if (status === 402 || code === "ENTITLEMENT_ERROR" || /plan limit/i.test(msg)) {
|
|
833
|
+
return new LinkError("PLAN_LIMIT", msg, status);
|
|
834
|
+
}
|
|
835
|
+
if (status >= 500) return new LinkError("SERVER", msg || "Server error", status);
|
|
836
|
+
return new LinkError("UNKNOWN", msg || fallback, status);
|
|
837
|
+
}
|
|
805
838
|
async function unwrap(res, ctx) {
|
|
806
|
-
if (!res) throw new
|
|
839
|
+
if (!res) throw new LinkError("NETWORK", `${ctx}: network error`);
|
|
807
840
|
if (!res.ok) {
|
|
808
|
-
let
|
|
841
|
+
let body = null;
|
|
809
842
|
try {
|
|
810
|
-
|
|
811
|
-
if (parsed2.error?.code || parsed2.error?.message) {
|
|
812
|
-
msg = `${parsed2.error.code ?? ""}${parsed2.error.code && parsed2.error.message ? ": " : ""}${parsed2.error.message ?? ""}`;
|
|
813
|
-
}
|
|
843
|
+
body = await res.clone().json();
|
|
814
844
|
} catch {
|
|
815
845
|
}
|
|
816
|
-
throw
|
|
846
|
+
throw mapToLinkError(res.status, body, `${ctx}: HTTP ${res.status}`);
|
|
817
847
|
}
|
|
818
848
|
const parsed = await res.json();
|
|
819
849
|
return parsed.data ?? null;
|
|
820
850
|
}
|
|
821
851
|
var SHORTCODE_RE = /^[a-z0-9-]{3,20}$/;
|
|
822
|
-
|
|
852
|
+
var RESERVED_TAILS = /* @__PURE__ */ new Set(["link"]);
|
|
853
|
+
function extractShortcodeFromUrl(url, linkDomain) {
|
|
823
854
|
try {
|
|
824
855
|
const u = new URL(url);
|
|
856
|
+
if (linkDomain) {
|
|
857
|
+
const wantHost = linkDomain.toLowerCase();
|
|
858
|
+
const gotHost = u.hostname.toLowerCase();
|
|
859
|
+
if (gotHost !== wantHost && !gotHost.endsWith(`.${wantHost}`)) return null;
|
|
860
|
+
}
|
|
825
861
|
const segs = u.pathname.split("/").filter(Boolean);
|
|
826
862
|
if (segs.length === 0) return null;
|
|
827
|
-
|
|
863
|
+
let tail = segs[segs.length - 1];
|
|
864
|
+
if (segs.length >= 2 && RESERVED_TAILS.has(segs[0]) && SHORTCODE_RE.test(tail)) {
|
|
865
|
+
return tail;
|
|
866
|
+
}
|
|
828
867
|
return SHORTCODE_RE.test(tail) ? tail : null;
|
|
829
868
|
} catch {
|
|
830
869
|
return null;
|
|
831
870
|
}
|
|
832
871
|
}
|
|
872
|
+
function dynRequire(id) {
|
|
873
|
+
const dyn = globalThis.require;
|
|
874
|
+
if (typeof dyn !== "function") return null;
|
|
875
|
+
try {
|
|
876
|
+
return dyn(id);
|
|
877
|
+
} catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
function loadRn() {
|
|
882
|
+
return dynRequire("react-native");
|
|
883
|
+
}
|
|
884
|
+
function readTimezone() {
|
|
885
|
+
try {
|
|
886
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
887
|
+
return tz || "UTC";
|
|
888
|
+
} catch {
|
|
889
|
+
return "UTC";
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function readLocale() {
|
|
893
|
+
const loc = dynRequire("expo-localization");
|
|
894
|
+
if (loc) {
|
|
895
|
+
const tag = loc.getLocales?.()[0]?.languageTag || loc.locale;
|
|
896
|
+
if (tag) return tag;
|
|
897
|
+
}
|
|
898
|
+
const rn = loadRn();
|
|
899
|
+
if (rn) {
|
|
900
|
+
const nm = rn.NativeModules;
|
|
901
|
+
const ios = nm.SettingsManager;
|
|
902
|
+
if (ios) {
|
|
903
|
+
const settings = ios.settings;
|
|
904
|
+
if (settings?.AppleLocale) return settings.AppleLocale;
|
|
905
|
+
if (Array.isArray(settings?.AppleLanguages) && settings.AppleLanguages[0]) return settings.AppleLanguages[0];
|
|
906
|
+
}
|
|
907
|
+
const android = nm.I18nManager;
|
|
908
|
+
if (android?.localeIdentifier) return android.localeIdentifier;
|
|
909
|
+
}
|
|
910
|
+
return "en-US";
|
|
911
|
+
}
|
|
912
|
+
async function sha256Hex(input) {
|
|
913
|
+
const g = globalThis;
|
|
914
|
+
if (g.crypto?.subtle?.digest) {
|
|
915
|
+
const enc = new TextEncoder().encode(input);
|
|
916
|
+
const buf = await g.crypto.subtle.digest("SHA-256", enc);
|
|
917
|
+
const view = new Uint8Array(buf);
|
|
918
|
+
return Array.from(view, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
919
|
+
}
|
|
920
|
+
const c = dynRequire("expo-crypto");
|
|
921
|
+
if (c?.digestStringAsync) {
|
|
922
|
+
const hex = await c.digestStringAsync("SHA-256", input);
|
|
923
|
+
return hex.toLowerCase();
|
|
924
|
+
}
|
|
925
|
+
return sha256HexPureJs(input);
|
|
926
|
+
}
|
|
927
|
+
function sha256HexPureJs(message) {
|
|
928
|
+
const utf8 = unescape(encodeURIComponent(message));
|
|
929
|
+
const bytes = new Uint8Array(utf8.length);
|
|
930
|
+
for (let i = 0; i < utf8.length; i++) bytes[i] = utf8.charCodeAt(i);
|
|
931
|
+
const bitLen = bytes.length * 8;
|
|
932
|
+
const padLen = bytes.length + 9 + 63 & ~63;
|
|
933
|
+
const buf = new Uint8Array(padLen);
|
|
934
|
+
buf.set(bytes);
|
|
935
|
+
buf[bytes.length] = 128;
|
|
936
|
+
const dv = new DataView(buf.buffer);
|
|
937
|
+
dv.setUint32(padLen - 4, bitLen >>> 0, false);
|
|
938
|
+
dv.setUint32(padLen - 8, Math.floor(bitLen / 4294967296), false);
|
|
939
|
+
const K = [
|
|
940
|
+
1116352408,
|
|
941
|
+
1899447441,
|
|
942
|
+
3049323471,
|
|
943
|
+
3921009573,
|
|
944
|
+
961987163,
|
|
945
|
+
1508970993,
|
|
946
|
+
2453635748,
|
|
947
|
+
2870763221,
|
|
948
|
+
3624381080,
|
|
949
|
+
310598401,
|
|
950
|
+
607225278,
|
|
951
|
+
1426881987,
|
|
952
|
+
1925078388,
|
|
953
|
+
2162078206,
|
|
954
|
+
2614888103,
|
|
955
|
+
3248222580,
|
|
956
|
+
3835390401,
|
|
957
|
+
4022224774,
|
|
958
|
+
264347078,
|
|
959
|
+
604807628,
|
|
960
|
+
770255983,
|
|
961
|
+
1249150122,
|
|
962
|
+
1555081692,
|
|
963
|
+
1996064986,
|
|
964
|
+
2554220882,
|
|
965
|
+
2821834349,
|
|
966
|
+
2952996808,
|
|
967
|
+
3210313671,
|
|
968
|
+
3336571891,
|
|
969
|
+
3584528711,
|
|
970
|
+
113926993,
|
|
971
|
+
338241895,
|
|
972
|
+
666307205,
|
|
973
|
+
773529912,
|
|
974
|
+
1294757372,
|
|
975
|
+
1396182291,
|
|
976
|
+
1695183700,
|
|
977
|
+
1986661051,
|
|
978
|
+
2177026350,
|
|
979
|
+
2456956037,
|
|
980
|
+
2730485921,
|
|
981
|
+
2820302411,
|
|
982
|
+
3259730800,
|
|
983
|
+
3345764771,
|
|
984
|
+
3516065817,
|
|
985
|
+
3600352804,
|
|
986
|
+
4094571909,
|
|
987
|
+
275423344,
|
|
988
|
+
430227734,
|
|
989
|
+
506948616,
|
|
990
|
+
659060556,
|
|
991
|
+
883997877,
|
|
992
|
+
958139571,
|
|
993
|
+
1322822218,
|
|
994
|
+
1537002063,
|
|
995
|
+
1747873779,
|
|
996
|
+
1955562222,
|
|
997
|
+
2024104815,
|
|
998
|
+
2227730452,
|
|
999
|
+
2361852424,
|
|
1000
|
+
2428436474,
|
|
1001
|
+
2756734187,
|
|
1002
|
+
3204031479,
|
|
1003
|
+
3329325298
|
|
1004
|
+
];
|
|
1005
|
+
const H = [1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225];
|
|
1006
|
+
const W = new Uint32Array(64);
|
|
1007
|
+
for (let chunk = 0; chunk < padLen; chunk += 64) {
|
|
1008
|
+
for (let i = 0; i < 16; i++) W[i] = dv.getUint32(chunk + i * 4, false);
|
|
1009
|
+
for (let i = 16; i < 64; i++) {
|
|
1010
|
+
const s0 = ror(W[i - 15], 7) ^ ror(W[i - 15], 18) ^ W[i - 15] >>> 3;
|
|
1011
|
+
const s1 = ror(W[i - 2], 17) ^ ror(W[i - 2], 19) ^ W[i - 2] >>> 10;
|
|
1012
|
+
W[i] = W[i - 16] + s0 + W[i - 7] + s1 >>> 0;
|
|
1013
|
+
}
|
|
1014
|
+
let [a, b, c, d, e, f, g, h] = H;
|
|
1015
|
+
for (let i = 0; i < 64; i++) {
|
|
1016
|
+
const S1 = ror(e, 6) ^ ror(e, 11) ^ ror(e, 25);
|
|
1017
|
+
const ch = e & f ^ ~e & g;
|
|
1018
|
+
const t1 = h + S1 + ch + K[i] + W[i] >>> 0;
|
|
1019
|
+
const S0 = ror(a, 2) ^ ror(a, 13) ^ ror(a, 22);
|
|
1020
|
+
const mj = a & b ^ a & c ^ b & c;
|
|
1021
|
+
const t2 = S0 + mj >>> 0;
|
|
1022
|
+
h = g;
|
|
1023
|
+
g = f;
|
|
1024
|
+
f = e;
|
|
1025
|
+
e = d + t1 >>> 0;
|
|
1026
|
+
d = c;
|
|
1027
|
+
c = b;
|
|
1028
|
+
b = a;
|
|
1029
|
+
a = t1 + t2 >>> 0;
|
|
1030
|
+
}
|
|
1031
|
+
H[0] = H[0] + a >>> 0;
|
|
1032
|
+
H[1] = H[1] + b >>> 0;
|
|
1033
|
+
H[2] = H[2] + c >>> 0;
|
|
1034
|
+
H[3] = H[3] + d >>> 0;
|
|
1035
|
+
H[4] = H[4] + e >>> 0;
|
|
1036
|
+
H[5] = H[5] + f >>> 0;
|
|
1037
|
+
H[6] = H[6] + g >>> 0;
|
|
1038
|
+
H[7] = H[7] + h >>> 0;
|
|
1039
|
+
}
|
|
1040
|
+
return H.map((x) => x.toString(16).padStart(8, "0")).join("");
|
|
1041
|
+
}
|
|
1042
|
+
function ror(x, n) {
|
|
1043
|
+
return (x >>> n | x << 32 - n) >>> 0;
|
|
1044
|
+
}
|
|
1045
|
+
async function computeDeviceFingerprint() {
|
|
1046
|
+
const rn = loadRn();
|
|
1047
|
+
const platform = rn?.Platform.OS ?? "unknown";
|
|
1048
|
+
let screen = "0x0";
|
|
1049
|
+
if (rn) {
|
|
1050
|
+
try {
|
|
1051
|
+
const d = rn.Dimensions.get("window");
|
|
1052
|
+
screen = `${Math.round(d.width)}x${Math.round(d.height)}`;
|
|
1053
|
+
} catch {
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const tz = readTimezone();
|
|
1057
|
+
const locale = readLocale();
|
|
1058
|
+
return sha256Hex(`${platform}|${screen}|${tz}|${locale}`);
|
|
1059
|
+
}
|
|
1060
|
+
async function getPlayInstallReferrer() {
|
|
1061
|
+
const rn = loadRn();
|
|
1062
|
+
if (!rn || rn.Platform.OS !== "android") return null;
|
|
1063
|
+
const mod = dynRequire("react-native-play-install-referrer");
|
|
1064
|
+
const cli = mod?.PlayInstallReferrer;
|
|
1065
|
+
if (!cli?.getInstallReferrerInfo) return null;
|
|
1066
|
+
return await new Promise((resolve) => {
|
|
1067
|
+
cli.getInstallReferrerInfo((err, info) => {
|
|
1068
|
+
if (err) {
|
|
1069
|
+
resolve(null);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
resolve(info?.installReferrer ?? null);
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
var PREWARM_TTL_MS = 5 * 6e4;
|
|
1077
|
+
var PREWARM_MAX = 50;
|
|
833
1078
|
var Links = class {
|
|
834
1079
|
constructor(deps) {
|
|
835
1080
|
this.deps = deps;
|
|
836
1081
|
this.listeners = [];
|
|
1082
|
+
this.prewarmCache = /* @__PURE__ */ new Map();
|
|
1083
|
+
this.linkingSub = null;
|
|
837
1084
|
}
|
|
838
|
-
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
1085
|
+
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
1086
|
+
* unsubscribe fn. Generic `T` lets callers type `linkData` per-app. */
|
|
839
1087
|
onLinkOpened(handler) {
|
|
840
|
-
|
|
1088
|
+
const widened = handler;
|
|
1089
|
+
this.listeners.push(widened);
|
|
841
1090
|
return () => {
|
|
842
|
-
const idx = this.listeners.indexOf(
|
|
1091
|
+
const idx = this.listeners.indexOf(widened);
|
|
843
1092
|
if (idx >= 0) this.listeners.splice(idx, 1);
|
|
844
1093
|
};
|
|
845
1094
|
}
|
|
@@ -852,11 +1101,58 @@ var Links = class {
|
|
|
852
1101
|
}
|
|
853
1102
|
}
|
|
854
1103
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
if (
|
|
859
|
-
const
|
|
1104
|
+
/* ------------------------ create + prewarm ------------------------- */
|
|
1105
|
+
/** Stable cache key. Customer can override per call with `opts.key`. */
|
|
1106
|
+
cacheKey(input, override) {
|
|
1107
|
+
if (override) return `k:${override}`;
|
|
1108
|
+
const sorted = {};
|
|
1109
|
+
const flat = input;
|
|
1110
|
+
for (const k of Object.keys(flat).sort()) sorted[k] = flat[k];
|
|
1111
|
+
return `s:${JSON.stringify(sorted)}`;
|
|
1112
|
+
}
|
|
1113
|
+
evictExpired() {
|
|
1114
|
+
const now = Date.now();
|
|
1115
|
+
for (const [k, v] of this.prewarmCache) {
|
|
1116
|
+
if (now - v.ts > PREWARM_TTL_MS) this.prewarmCache.delete(k);
|
|
1117
|
+
}
|
|
1118
|
+
while (this.prewarmCache.size > PREWARM_MAX) {
|
|
1119
|
+
const first = this.prewarmCache.keys().next().value;
|
|
1120
|
+
if (first === void 0) break;
|
|
1121
|
+
this.prewarmCache.delete(first);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Background-mint a link + cache the promise so a subsequent `create()`
|
|
1126
|
+
* with the same input (or matching `key`) returns instantly.
|
|
1127
|
+
*
|
|
1128
|
+
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
1129
|
+
* will surface them on demand instead.
|
|
1130
|
+
*/
|
|
1131
|
+
prewarm(input, opts) {
|
|
1132
|
+
this.evictExpired();
|
|
1133
|
+
const key = this.cacheKey(input, opts?.key);
|
|
1134
|
+
if (this.prewarmCache.has(key)) return;
|
|
1135
|
+
const promise = this.doCreate(input).catch((err) => {
|
|
1136
|
+
this.prewarmCache.delete(key);
|
|
1137
|
+
throw err;
|
|
1138
|
+
});
|
|
1139
|
+
this.prewarmCache.set(key, { promise, ts: Date.now() });
|
|
1140
|
+
}
|
|
1141
|
+
/** Mint a new short link. Uses prewarm cache when the input matches. */
|
|
1142
|
+
async create(input, opts) {
|
|
1143
|
+
if (!input.title) throw new LinkError("INVALID_INPUT", "links.create: title is required");
|
|
1144
|
+
this.evictExpired();
|
|
1145
|
+
const key = this.cacheKey(input, opts?.key);
|
|
1146
|
+
const cached = this.prewarmCache.get(key);
|
|
1147
|
+
if (cached) {
|
|
1148
|
+
this.prewarmCache.delete(key);
|
|
1149
|
+
return cached.promise;
|
|
1150
|
+
}
|
|
1151
|
+
return this.doCreate(input);
|
|
1152
|
+
}
|
|
1153
|
+
async doCreate(input) {
|
|
1154
|
+
const body = { title: input.title };
|
|
1155
|
+
if (input.fallbackUrl) body.fallbackUrl = input.fallbackUrl;
|
|
860
1156
|
if (input.iosDeepLinkPath) body.iosDeepLinkPath = input.iosDeepLinkPath;
|
|
861
1157
|
if (input.androidDeepLinkPath) body.androidDeepLinkPath = input.androidDeepLinkPath;
|
|
862
1158
|
if (input.linkData) body.linkData = input.linkData;
|
|
@@ -869,8 +1165,9 @@ var Links = class {
|
|
|
869
1165
|
if (input.channel) body.channel = input.channel;
|
|
870
1166
|
if (input.tags) body.tags = input.tags;
|
|
871
1167
|
if (input.expiresAt) body.expiresAt = input.expiresAt;
|
|
872
|
-
const
|
|
873
|
-
const
|
|
1168
|
+
const rnPlatform = loadRn()?.Platform.OS;
|
|
1169
|
+
const ios = input.iosBundleId ?? (rnPlatform === "ios" ? this.deps.iosBundleId : void 0);
|
|
1170
|
+
const android = input.androidPackageName ?? (rnPlatform === "android" ? this.deps.androidPackageName : void 0);
|
|
874
1171
|
if (ios) body.iosBundleId = ios;
|
|
875
1172
|
if (android) body.androidPackageName = android;
|
|
876
1173
|
const res = await post(
|
|
@@ -880,17 +1177,18 @@ var Links = class {
|
|
|
880
1177
|
);
|
|
881
1178
|
const data = await unwrap(res, "links.create");
|
|
882
1179
|
if (!data?.shortcode) {
|
|
883
|
-
throw new
|
|
1180
|
+
throw new LinkError("UNKNOWN", "links.create returned no shortcode");
|
|
884
1181
|
}
|
|
885
1182
|
return data;
|
|
886
1183
|
}
|
|
1184
|
+
/* -------------------- warm + deferred resolve ---------------------- */
|
|
887
1185
|
/**
|
|
888
1186
|
* Warm-path: app received a universal/app-link delivery. Resolves the
|
|
889
1187
|
* shortcode against the backend and fires `onLinkOpened`. No-op (and
|
|
890
1188
|
* returns false) when the URL isn't a Sendora link.
|
|
891
1189
|
*/
|
|
892
1190
|
async handleUniversalLink(url) {
|
|
893
|
-
const shortcode = extractShortcodeFromUrl(url);
|
|
1191
|
+
const shortcode = extractShortcodeFromUrl(url, this.deps.linkDomain);
|
|
894
1192
|
if (!shortcode) return false;
|
|
895
1193
|
try {
|
|
896
1194
|
const res = await getJson(
|
|
@@ -909,26 +1207,44 @@ var Links = class {
|
|
|
909
1207
|
return true;
|
|
910
1208
|
} catch (err) {
|
|
911
1209
|
if (this.deps.debug) console.warn("[sendoracloud] handleUniversalLink failed:", err);
|
|
1210
|
+
if (err instanceof LinkError) throw err;
|
|
912
1211
|
return false;
|
|
913
1212
|
}
|
|
914
1213
|
}
|
|
915
1214
|
/**
|
|
916
|
-
* Cold-launch deferred deep link match.
|
|
917
|
-
*
|
|
918
|
-
*
|
|
1215
|
+
* Cold-launch deferred deep link match. When neither `fingerprintHash` nor
|
|
1216
|
+
* `installReferrer` is supplied, the SDK auto-discovers:
|
|
1217
|
+
* - Android: probes `react-native-play-install-referrer` (preferred — 100%
|
|
1218
|
+
* accurate when present).
|
|
1219
|
+
* - Otherwise: computes the canonical device fingerprint hash.
|
|
919
1220
|
*
|
|
920
|
-
*
|
|
921
|
-
* Returns the event payload on match, `null` on no match.
|
|
1221
|
+
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
922
1222
|
*/
|
|
923
|
-
async matchDeferred(input) {
|
|
924
|
-
|
|
925
|
-
|
|
1223
|
+
async matchDeferred(input = {}) {
|
|
1224
|
+
let fingerprintHash = input.fingerprintHash;
|
|
1225
|
+
let installReferrer = input.installReferrer;
|
|
1226
|
+
if (!fingerprintHash && !installReferrer) {
|
|
1227
|
+
installReferrer = await getPlayInstallReferrer() ?? void 0;
|
|
1228
|
+
if (!installReferrer) {
|
|
1229
|
+
try {
|
|
1230
|
+
fingerprintHash = await computeDeviceFingerprint();
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
if (this.deps.debug) console.warn("[sendoracloud] fingerprint compute failed:", err);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (!fingerprintHash && !installReferrer) {
|
|
1237
|
+
throw new LinkError(
|
|
1238
|
+
"INVALID_INPUT",
|
|
1239
|
+
"links.matchDeferred: install `react-native-play-install-referrer` for Android, or ensure web crypto / expo-crypto is available for iOS fingerprint compute."
|
|
1240
|
+
);
|
|
926
1241
|
}
|
|
927
1242
|
const body = {};
|
|
928
|
-
if (
|
|
929
|
-
if (
|
|
930
|
-
const
|
|
931
|
-
const
|
|
1243
|
+
if (fingerprintHash) body.fingerprintHash = fingerprintHash;
|
|
1244
|
+
if (installReferrer) body.installReferrer = installReferrer;
|
|
1245
|
+
const rnPlatform = loadRn()?.Platform.OS;
|
|
1246
|
+
const ios = input.iosBundleId ?? (rnPlatform === "ios" ? this.deps.iosBundleId : void 0);
|
|
1247
|
+
const android = input.androidPackageName ?? (rnPlatform === "android" ? this.deps.androidPackageName : void 0);
|
|
932
1248
|
if (ios) body.iosBundleId = ios;
|
|
933
1249
|
if (android) body.androidPackageName = android;
|
|
934
1250
|
const res = await post(
|
|
@@ -948,6 +1264,99 @@ var Links = class {
|
|
|
948
1264
|
this.emit(event);
|
|
949
1265
|
return event;
|
|
950
1266
|
}
|
|
1267
|
+
/* --------------------------- revoke ------------------------------- */
|
|
1268
|
+
/** Soft-delete a link. Idempotent — repeated calls succeed. */
|
|
1269
|
+
async revoke(shortcode) {
|
|
1270
|
+
if (!SHORTCODE_RE.test(shortcode)) {
|
|
1271
|
+
throw new LinkError("INVALID_INPUT", `links.revoke: '${shortcode}' is not a valid shortcode`);
|
|
1272
|
+
}
|
|
1273
|
+
const res = await post(
|
|
1274
|
+
{ apiUrl: this.deps.apiUrl, publicKey: this.deps.publicKey, debug: this.deps.debug },
|
|
1275
|
+
`/api/v1/sdk/links/${encodeURIComponent(shortcode)}/revoke`,
|
|
1276
|
+
{}
|
|
1277
|
+
);
|
|
1278
|
+
const data = await unwrap(res, "links.revoke");
|
|
1279
|
+
if (!data) throw new LinkError("UNKNOWN", "links.revoke returned empty body");
|
|
1280
|
+
return { shortcode: data.shortcode, revoked: true };
|
|
1281
|
+
}
|
|
1282
|
+
/* ---------------------------- stats ------------------------------- */
|
|
1283
|
+
/** Click totals + breakdowns for a link the SDK owns. */
|
|
1284
|
+
async getStats(shortcode) {
|
|
1285
|
+
if (!SHORTCODE_RE.test(shortcode)) {
|
|
1286
|
+
throw new LinkError("INVALID_INPUT", `links.getStats: '${shortcode}' is not a valid shortcode`);
|
|
1287
|
+
}
|
|
1288
|
+
const res = await getJson(
|
|
1289
|
+
{ apiUrl: this.deps.apiUrl, publicKey: this.deps.publicKey, debug: this.deps.debug },
|
|
1290
|
+
`/api/v1/sdk/links/${encodeURIComponent(shortcode)}/stats`
|
|
1291
|
+
);
|
|
1292
|
+
const data = await unwrap(res, "links.getStats");
|
|
1293
|
+
if (!data) throw new LinkError("UNKNOWN", "links.getStats returned empty body");
|
|
1294
|
+
return data;
|
|
1295
|
+
}
|
|
1296
|
+
/* ----------------------- Linking-API wiring ----------------------- */
|
|
1297
|
+
/**
|
|
1298
|
+
* One-call replacement for the standard cold + warm URL boilerplate:
|
|
1299
|
+
*
|
|
1300
|
+
* const detach = await sendora.links.attachLinkingApi({
|
|
1301
|
+
* onLegacyUrl: (url) => myRouter.handle(url),
|
|
1302
|
+
* });
|
|
1303
|
+
* // …later in unmount:
|
|
1304
|
+
* detach();
|
|
1305
|
+
*
|
|
1306
|
+
* Internally:
|
|
1307
|
+
* - Reads `Linking.getInitialURL()` once.
|
|
1308
|
+
* - Subscribes to `Linking.addEventListener('url', …)`.
|
|
1309
|
+
* - For each URL: checks `extractShortcodeFromUrl(url, linkDomain)`.
|
|
1310
|
+
* Sendora-shaped → calls `handleUniversalLink`.
|
|
1311
|
+
* Otherwise → forwards to `onLegacyUrl` (if supplied).
|
|
1312
|
+
*
|
|
1313
|
+
* Idempotent: re-attaching while a subscription is active replaces it.
|
|
1314
|
+
*/
|
|
1315
|
+
async attachLinkingApi(opts = {}) {
|
|
1316
|
+
const rn = loadRn();
|
|
1317
|
+
if (!rn) throw new LinkError("UNKNOWN", "react-native Linking is not available in this runtime");
|
|
1318
|
+
const Linking = rn.Linking;
|
|
1319
|
+
if (!Linking) throw new LinkError("UNKNOWN", "react-native Linking module is undefined");
|
|
1320
|
+
if (this.linkingSub) {
|
|
1321
|
+
try {
|
|
1322
|
+
this.linkingSub.remove();
|
|
1323
|
+
} catch {
|
|
1324
|
+
}
|
|
1325
|
+
this.linkingSub = null;
|
|
1326
|
+
}
|
|
1327
|
+
const handle = async (url) => {
|
|
1328
|
+
if (!url) return;
|
|
1329
|
+
const sc = extractShortcodeFromUrl(url, this.deps.linkDomain);
|
|
1330
|
+
if (sc) {
|
|
1331
|
+
try {
|
|
1332
|
+
await this.handleUniversalLink(url);
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
if (this.deps.debug) console.warn("[sendoracloud] attachLinkingApi resolve failed:", err);
|
|
1335
|
+
}
|
|
1336
|
+
} else if (opts.onLegacyUrl) {
|
|
1337
|
+
try {
|
|
1338
|
+
opts.onLegacyUrl(url);
|
|
1339
|
+
} catch (err) {
|
|
1340
|
+
if (this.deps.debug) console.warn("[sendoracloud] onLegacyUrl handler threw:", err);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
try {
|
|
1345
|
+
await handle(await Linking.getInitialURL());
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
if (this.deps.debug) console.warn("[sendoracloud] getInitialURL failed:", err);
|
|
1348
|
+
}
|
|
1349
|
+
this.linkingSub = Linking.addEventListener("url", (ev) => {
|
|
1350
|
+
void handle(ev?.url ?? null);
|
|
1351
|
+
});
|
|
1352
|
+
return () => {
|
|
1353
|
+
try {
|
|
1354
|
+
this.linkingSub?.remove();
|
|
1355
|
+
} catch {
|
|
1356
|
+
}
|
|
1357
|
+
this.linkingSub = null;
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
951
1360
|
};
|
|
952
1361
|
|
|
953
1362
|
// src/auto-track.ts
|
|
@@ -1033,7 +1442,7 @@ function installAutoTrack(args) {
|
|
|
1033
1442
|
}
|
|
1034
1443
|
|
|
1035
1444
|
// src/index.ts
|
|
1036
|
-
var SDK_VERSION = "0.
|
|
1445
|
+
var SDK_VERSION = "0.16.1";
|
|
1037
1446
|
var DEFAULT_API_URL = "https://api.sendoracloud.com";
|
|
1038
1447
|
var ANON_KEY = "anon_id";
|
|
1039
1448
|
var USER_ID_KEY = "user_id";
|
|
@@ -1177,7 +1586,8 @@ var SendoraSDK = class {
|
|
|
1177
1586
|
publicKey: this.config.publicKey,
|
|
1178
1587
|
debug: this.debug,
|
|
1179
1588
|
iosBundleId: this.config.iosBundleId,
|
|
1180
|
-
androidPackageName: this.config.androidPackageName
|
|
1589
|
+
androidPackageName: this.config.androidPackageName,
|
|
1590
|
+
linkDomain: this.config.linkDomain
|
|
1181
1591
|
});
|
|
1182
1592
|
this.ready = true;
|
|
1183
1593
|
if (config.autoTrack !== false) {
|
|
@@ -1372,8 +1782,11 @@ export {
|
|
|
1372
1782
|
Auth,
|
|
1373
1783
|
AuthError,
|
|
1374
1784
|
EmailAlreadyTakenError,
|
|
1785
|
+
LinkError,
|
|
1375
1786
|
Links,
|
|
1376
1787
|
SendoraCloud,
|
|
1788
|
+
computeDeviceFingerprint,
|
|
1377
1789
|
index_default as default,
|
|
1378
|
-
extractShortcodeFromUrl
|
|
1790
|
+
extractShortcodeFromUrl,
|
|
1791
|
+
getPlayInstallReferrer
|
|
1379
1792
|
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example: app-root deep-link router (warm + deferred + legacy fallthrough).
|
|
3
|
+
*
|
|
4
|
+
* Mount once at app root. Replaces the ~20-line Linking.getInitialURL +
|
|
5
|
+
* addEventListener + extract-pre-check + handle-or-fallthrough recipe with
|
|
6
|
+
* a single `attachLinkingApi` call.
|
|
7
|
+
*
|
|
8
|
+
* Copy into your app and adjust `routeFromLinkData` for your nav schema.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect } from "react";
|
|
12
|
+
import SendoraCloud from "@sendoracloud/sdk-react-native";
|
|
13
|
+
|
|
14
|
+
// Discriminated union — every deep link your app accepts.
|
|
15
|
+
type DeepLinkPayload =
|
|
16
|
+
| { type: "article"; articleId: string; category: "tech" | "design" | "policy"; sharedBy: string }
|
|
17
|
+
| { type: "profile"; userId: string }
|
|
18
|
+
| { type: "comment"; articleId: string; commentId: string };
|
|
19
|
+
|
|
20
|
+
function routeFromLinkData(linkData: DeepLinkPayload): void {
|
|
21
|
+
switch (linkData.type) {
|
|
22
|
+
case "article":
|
|
23
|
+
// navigation.navigate("Article", { id: linkData.articleId });
|
|
24
|
+
break;
|
|
25
|
+
case "profile":
|
|
26
|
+
// navigation.navigate("Profile", { userId: linkData.userId });
|
|
27
|
+
break;
|
|
28
|
+
case "comment":
|
|
29
|
+
// navigation.navigate("Article", { id: linkData.articleId, scrollTo: linkData.commentId });
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useSendoraLinkRouter(): void {
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const subs: Array<() => void> = [];
|
|
37
|
+
|
|
38
|
+
// 1. Subscribe to all opens — warm + deferred — with typed linkData.
|
|
39
|
+
subs.push(
|
|
40
|
+
SendoraCloud.links.onLinkOpened<DeepLinkPayload>((event) => {
|
|
41
|
+
if (event.isDeferred) {
|
|
42
|
+
// Cold-launch attribution: navigate after the app's initial
|
|
43
|
+
// splash → home transition completes (or schedule a redirect
|
|
44
|
+
// via your nav library's `setParams` once a screen is ready).
|
|
45
|
+
}
|
|
46
|
+
routeFromLinkData(event.linkData);
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// 2. Wire the Linking API. Sendora-shaped URLs flow into
|
|
51
|
+
// handleUniversalLink → onLinkOpened automatically. Anything else
|
|
52
|
+
// is forwarded to onLegacyUrl so your existing router still handles
|
|
53
|
+
// OAuth callbacks, magic links, etc.
|
|
54
|
+
let detach: (() => void) | undefined;
|
|
55
|
+
void SendoraCloud.links
|
|
56
|
+
.attachLinkingApi({
|
|
57
|
+
onLegacyUrl: (url) => {
|
|
58
|
+
// myExistingRouter.handle(url);
|
|
59
|
+
console.log("non-Sendora URL", url);
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
.then((d) => {
|
|
63
|
+
detach = d;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// 3. Cold-launch deferred match — call once on first foregrounded
|
|
67
|
+
// launch. SDK auto-picks installReferrer (Android) or
|
|
68
|
+
// fingerprintHash (iOS / fallback) — no boilerplate.
|
|
69
|
+
void SendoraCloud.links.matchDeferred().catch((err) => {
|
|
70
|
+
// matchDeferred only throws when both probe paths fail.
|
|
71
|
+
console.log("deferred match probe skipped:", err);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
detach?.();
|
|
76
|
+
subs.forEach((fn) => fn());
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
}
|