@gusnips/native 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +310 -2
- package/dist/device-locale.d.ts +15 -0
- package/dist/device-locale.d.ts.map +1 -0
- package/dist/device-locale.js +27 -0
- package/dist/device-locale.js.map +1 -0
- package/dist/i18n.d.ts +34 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +63 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/network.d.ts +18 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +35 -0
- package/dist/network.js.map +1 -0
- package/dist/node/bin/gusnips-native-css.d.ts +3 -0
- package/dist/node/bin/gusnips-native-css.d.ts.map +1 -0
- package/dist/node/bin/gusnips-native-css.js +75 -0
- package/dist/node/bin/gusnips-native-css.js.map +1 -0
- package/dist/node/css.d.ts +44 -0
- package/dist/node/css.d.ts.map +1 -0
- package/dist/node/css.js +181 -0
- package/dist/node/css.js.map +1 -0
- package/dist/node/metro.d.ts +51 -0
- package/dist/node/metro.d.ts.map +1 -0
- package/dist/node/metro.js +100 -0
- package/dist/node/metro.js.map +1 -0
- package/dist/query.d.ts +13 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +37 -0
- package/dist/query.js.map +1 -0
- package/dist/sse.d.ts +47 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +135 -0
- package/dist/sse.js.map +1 -0
- package/dist/supabase.d.ts +18 -0
- package/dist/supabase.d.ts.map +1 -0
- package/dist/supabase.js +29 -0
- package/dist/supabase.js.map +1 -0
- package/dist/theme.d.ts +25 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +99 -0
- package/dist/theme.js.map +1 -0
- package/package.json +150 -4
- package/src/device-locale.test.ts +33 -0
- package/src/device-locale.ts +28 -0
- package/src/i18n.ts +91 -0
- package/src/index.ts +18 -0
- package/src/network.ts +37 -0
- package/src/node/bin/gusnips-native-css.ts +84 -0
- package/src/node/css.ts +246 -0
- package/src/node/metro.test.ts +66 -0
- package/src/node/metro.ts +143 -0
- package/src/query.ts +41 -0
- package/src/sse.test.ts +195 -0
- package/src/sse.ts +174 -0
- package/src/supabase.ts +29 -0
- package/src/theme.test.ts +92 -0
- package/src/theme.ts +132 -0
package/dist/sse.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-sent events on React Native, over `XMLHttpRequest`.
|
|
3
|
+
*
|
|
4
|
+
* React Native's `fetch` never gives a response body you can read as it arrives: there is no
|
|
5
|
+
* `ReadableStream` on it, so `readSseStream` from `@gusnips/react` has nothing to read. XHR does
|
|
6
|
+
* fire `onprogress` with a `responseText` that grows as bytes land, and that is the channel every
|
|
7
|
+
* RN app streaming from a server ends up on. The framing is `@gusnips/react`'s `createSseParser`,
|
|
8
|
+
* the same state machine the web reads with. Only the feed is different: here it is handed the new
|
|
9
|
+
* slice of `responseText` on each progress event.
|
|
10
|
+
*
|
|
11
|
+
* A second transport has a cost the kit already paid for once: it drifts from the API client. So
|
|
12
|
+
* this one keeps to the client's contract where it can. A refused handshake throws the client's own
|
|
13
|
+
* `ApiError`, read from the same envelope and the same `Retry-After` header, so a 402 on a stream
|
|
14
|
+
* gets the sentence and the button any other 402 in the app gets. The token and the 401 refresh come
|
|
15
|
+
* from the same `SessionAdapter` the client is built on. Single flight is the adapter's job and this
|
|
16
|
+
* adds none of its own, so a stream opening mid-load joins the refresh every other request is
|
|
17
|
+
* waiting on instead of racing it. GoTrue rotates the refresh token on use, and the loser of that race would sign the
|
|
18
|
+
* person out. The donor kept a hand-rolled single flight beside its client for this; it existed only
|
|
19
|
+
* because the adapter did not have one.
|
|
20
|
+
*
|
|
21
|
+
* No reconnect. The donor's chat store had its own recovery (it refetches the transcript when a
|
|
22
|
+
* stream breaks), and a loop here would have been a second one nobody asked for.
|
|
23
|
+
*/
|
|
24
|
+
import { isApiError } from "@gusnips/http";
|
|
25
|
+
import { parseRetryAfter } from "@gusnips/http/retry";
|
|
26
|
+
import { ApiError, createSseParser } from "@gusnips/react";
|
|
27
|
+
/** The request id header `createApiClient` reads by default. */
|
|
28
|
+
const REQUEST_ID_HEADER = "x-request-id";
|
|
29
|
+
/**
|
|
30
|
+
* Open a stream. It starts at once; keep the handle to stop it.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* const stream = openSseStream({
|
|
34
|
+
* url: `${API_URL}/chat/stream`,
|
|
35
|
+
* body: { message },
|
|
36
|
+
* session, // the same adapter createApiClient uses
|
|
37
|
+
* onFrame: ({ event, data }) => handle(event, JSON.parse(data)),
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* ponytail: XHR keeps the whole response in `responseText`, so memory grows with the length of the
|
|
42
|
+
* stream. A chat turn is fine. A feed left open all day is not; that needs a native streaming module.
|
|
43
|
+
*/
|
|
44
|
+
export function openSseStream(options) {
|
|
45
|
+
const { url, method = "POST", body, session, headers, onFrame, onError, onClose } = options;
|
|
46
|
+
let aborted = false;
|
|
47
|
+
let refreshed = false;
|
|
48
|
+
let current = null;
|
|
49
|
+
const fail = (error) => {
|
|
50
|
+
if (aborted)
|
|
51
|
+
return;
|
|
52
|
+
onError?.(error);
|
|
53
|
+
onClose?.();
|
|
54
|
+
};
|
|
55
|
+
const asError = (thrown, fallback) => thrown instanceof Error ? thrown : new Error(fallback);
|
|
56
|
+
function start(token) {
|
|
57
|
+
if (aborted)
|
|
58
|
+
return;
|
|
59
|
+
const xhr = new XMLHttpRequest();
|
|
60
|
+
current = xhr;
|
|
61
|
+
let read = 0;
|
|
62
|
+
const parser = createSseParser((frame) => {
|
|
63
|
+
if (!aborted)
|
|
64
|
+
onFrame(frame);
|
|
65
|
+
});
|
|
66
|
+
xhr.open(method, url);
|
|
67
|
+
if (method !== "GET")
|
|
68
|
+
xhr.setRequestHeader("Content-Type", "application/json");
|
|
69
|
+
xhr.setRequestHeader("Accept", "text/event-stream");
|
|
70
|
+
for (const [name, value] of Object.entries(headers?.() ?? {}))
|
|
71
|
+
xhr.setRequestHeader(name, value);
|
|
72
|
+
if (token)
|
|
73
|
+
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
|
74
|
+
// `responseText` is everything so far, not the new part. Feed only what was not fed yet, or
|
|
75
|
+
// every frame is delivered again on every progress event.
|
|
76
|
+
const drain = () => {
|
|
77
|
+
if (aborted || xhr.responseText.length <= read)
|
|
78
|
+
return;
|
|
79
|
+
const next = xhr.responseText.slice(read);
|
|
80
|
+
read = xhr.responseText.length;
|
|
81
|
+
parser.feed(next);
|
|
82
|
+
};
|
|
83
|
+
xhr.onprogress = drain;
|
|
84
|
+
xhr.onload = () => {
|
|
85
|
+
if (aborted)
|
|
86
|
+
return;
|
|
87
|
+
if (xhr.status === 401 && session && !refreshed) {
|
|
88
|
+
refreshed = true;
|
|
89
|
+
const refused = refusalOf(xhr);
|
|
90
|
+
// No token back means no session to retry with, whether auth refused or never answered.
|
|
91
|
+
// The stream ends with the 401; signing out stays the API client's call, so a dead session
|
|
92
|
+
// has one place that ends it.
|
|
93
|
+
session.refresh().then(({ token: fresh }) => (fresh ? start(fresh) : fail(refused)), (thrown) => fail(asError(thrown, "Could not refresh the session")));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (xhr.status < 200 || xhr.status >= 300) {
|
|
97
|
+
fail(refusalOf(xhr));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// A last frame the server closed without its blank line. Two newlines close it whether the
|
|
101
|
+
// stream ended mid-frame or after one newline; after a complete frame they add nothing.
|
|
102
|
+
drain();
|
|
103
|
+
parser.feed("\n\n");
|
|
104
|
+
onClose?.();
|
|
105
|
+
};
|
|
106
|
+
xhr.onerror = () => fail(new Error("The stream lost its connection"));
|
|
107
|
+
xhr.ontimeout = () => fail(new Error("The stream timed out"));
|
|
108
|
+
xhr.send(method === "GET" ? null : JSON.stringify(body ?? {}));
|
|
109
|
+
}
|
|
110
|
+
(session ? session.getToken() : Promise.resolve(null)).then(start, (thrown) => fail(asError(thrown, "Could not read the session")));
|
|
111
|
+
return {
|
|
112
|
+
get aborted() {
|
|
113
|
+
return aborted;
|
|
114
|
+
},
|
|
115
|
+
abort() {
|
|
116
|
+
aborted = true;
|
|
117
|
+
current?.abort();
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** The refusal as the API client would have thrown it: the envelope, the request id, the wait. */
|
|
122
|
+
function refusalOf(xhr) {
|
|
123
|
+
let parsed = null;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(xhr.responseText);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Not JSON, such as a gateway's HTML page. The status is all there is.
|
|
129
|
+
}
|
|
130
|
+
return new ApiError(xhr.status, isApiError(parsed) ? parsed.error : null, {
|
|
131
|
+
requestId: xhr.getResponseHeader(REQUEST_ID_HEADER) ?? undefined,
|
|
132
|
+
retryAfterSecs: parseRetryAfter(xhr.getResponseHeader("retry-after")),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=sse.js.map
|
package/dist/sse.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.js","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAsC,MAAM,gBAAgB,CAAC;AAiC/F,gEAAgE;AAChE,MAAM,iBAAiB,GAAG,cAAc,CAAC;AAEzC;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,aAAa,CAAC,OAAyB;IACrD,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAC5F,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,IAAI,SAAS,GAAG,KAAK,CAAC;IACtB,IAAI,OAAO,GAA0B,IAAI,CAAC;IAE1C,MAAM,IAAI,GAAG,CAAC,KAAY,EAAE,EAAE;QAC5B,IAAI,OAAO;YAAE,OAAO;QACpB,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;QACjB,OAAO,EAAE,EAAE,CAAC;IACd,CAAC,CAAC;IACF,MAAM,OAAO,GAAG,CAAC,MAAe,EAAE,QAAgB,EAAE,EAAE,CACpD,MAAM,YAAY,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC;IAEzD,SAAS,KAAK,CAAC,KAAoB;QACjC,IAAI,OAAO;YAAE,OAAO;QACpB,MAAM,GAAG,GAAG,IAAI,cAAc,EAAE,CAAC;QACjC,OAAO,GAAG,GAAG,CAAC;QACd,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,MAAM,MAAM,GAAG,eAAe,CAAC,CAAC,KAAK,EAAE,EAAE;YACvC,IAAI,CAAC,OAAO;gBAAE,OAAO,CAAC,KAAK,CAAC,CAAC;QAC/B,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QACtB,IAAI,MAAM,KAAK,KAAK;YAAE,GAAG,CAAC,gBAAgB,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAC;QAC/E,GAAG,CAAC,gBAAgB,CAAC,QAAQ,EAAE,mBAAmB,CAAC,CAAC;QACpD,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC;YAC3D,GAAG,CAAC,gBAAgB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QACpC,IAAI,KAAK;YAAE,GAAG,CAAC,gBAAgB,CAAC,eAAe,EAAE,UAAU,KAAK,EAAE,CAAC,CAAC;QAEpE,4FAA4F;QAC5F,0DAA0D;QAC1D,MAAM,KAAK,GAAG,GAAG,EAAE;YACjB,IAAI,OAAO,IAAI,GAAG,CAAC,YAAY,CAAC,MAAM,IAAI,IAAI;gBAAE,OAAO;YACvD,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAC1C,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC;YAC/B,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACpB,CAAC,CAAC;QACF,GAAG,CAAC,UAAU,GAAG,KAAK,CAAC;QAEvB,GAAG,CAAC,MAAM,GAAG,GAAG,EAAE;YAChB,IAAI,OAAO;gBAAE,OAAO;YACpB,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,IAAI,OAAO,IAAI,CAAC,SAAS,EAAE,CAAC;gBAChD,SAAS,GAAG,IAAI,CAAC;gBACjB,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;gBAC/B,wFAAwF;gBACxF,2FAA2F;gBAC3F,8BAA8B;gBAC9B,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CACpB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,EAC5D,CAAC,MAAe,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,+BAA+B,CAAC,CAAC,CAC5E,CAAC;gBACF,OAAO;YACT,CAAC;YACD,IAAI,GAAG,CAAC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG,EAAE,CAAC;gBAC1C,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;gBACrB,OAAO;YACT,CAAC;YACD,2FAA2F;YAC3F,wFAAwF;YACxF,KAAK,EAAE,CAAC;YACR,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACpB,OAAO,EAAE,EAAE,CAAC;QACd,CAAC,CAAC;QACF,GAAG,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC,CAAC;QACtE,GAAG,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,sBAAsB,CAAC,CAAC,CAAC;QAE9D,GAAG,CAAC,IAAI,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC;IACjE,CAAC;IAED,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,MAAe,EAAE,EAAE,CACrF,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,4BAA4B,CAAC,CAAC,CACpD,CAAC;IAEF,OAAO;QACL,IAAI,OAAO;YACT,OAAO,OAAO,CAAC;QACjB,CAAC;QACD,KAAK;YACH,OAAO,GAAG,IAAI,CAAC;YACf,OAAO,EAAE,KAAK,EAAE,CAAC;QACnB,CAAC;KACF,CAAC;AACJ,CAAC;AAED,kGAAkG;AAClG,SAAS,SAAS,CAAC,GAAmB;IACpC,IAAI,MAAM,GAAY,IAAI,CAAC;IAC3B,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;IACxC,CAAC;IAAC,MAAM,CAAC;QACP,uEAAuE;IACzE,CAAC;IACD,OAAO,IAAI,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE;QACxE,SAAS,EAAE,GAAG,CAAC,iBAAiB,CAAC,iBAAiB,CAAC,IAAI,SAAS;QAChE,cAAc,EAAE,eAAe,CAAC,GAAG,CAAC,iBAAiB,CAAC,aAAa,CAAC,CAAC;KACtE,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SupabaseSessionStorage } from "@gusnips/react/supabase";
|
|
2
|
+
/**
|
|
3
|
+
* The phone's `supabaseAuthStorage`. Build it once and pass it both to `createClient`'s `auth` and
|
|
4
|
+
* to `signOutEvenOffline`, so the sign-out clears the exact key the client reads:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* export const authStorage = supabaseAuthStorage(SUPABASE_URL);
|
|
8
|
+
* export const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
|
|
9
|
+
* auth: { ...authStorage, detectSessionInUrl: false, flowType: "pkce" },
|
|
10
|
+
* });
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The key is the one supabase-js picks for itself, so moving an app onto this signs nobody out.
|
|
14
|
+
* On a phone, pass `flowType: "pkce"` as above: a sign-in comes back through a custom scheme any app
|
|
15
|
+
* can claim, and under the implicit flow that URL carries the refresh token itself.
|
|
16
|
+
*/
|
|
17
|
+
export declare function supabaseAuthStorage(supabaseUrl: string): SupabaseSessionStorage;
|
|
18
|
+
//# sourceMappingURL=supabase.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../src/supabase.ts"],"names":[],"mappings":"AASA,OAAO,EAAsB,KAAK,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAE1F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,sBAAsB,CAE/E"}
|
package/dist/supabase.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where supabase-js keeps the session on a phone.
|
|
3
|
+
*
|
|
4
|
+
* AsyncStorage and not the keychain (or a SecureStore): a session is the access token, the refresh
|
|
5
|
+
* token and the user's metadata in one JSON value, and it routinely passes the roughly 2 KB a
|
|
6
|
+
* platform keystore takes per value. AsyncStorage is private to the app on both platforms, and it is
|
|
7
|
+
* what Supabase's own React Native guide uses.
|
|
8
|
+
*/
|
|
9
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
10
|
+
import { supabaseStorageKey } from "@gusnips/react/supabase";
|
|
11
|
+
/**
|
|
12
|
+
* The phone's `supabaseAuthStorage`. Build it once and pass it both to `createClient`'s `auth` and
|
|
13
|
+
* to `signOutEvenOffline`, so the sign-out clears the exact key the client reads:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* export const authStorage = supabaseAuthStorage(SUPABASE_URL);
|
|
17
|
+
* export const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
|
|
18
|
+
* auth: { ...authStorage, detectSessionInUrl: false, flowType: "pkce" },
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The key is the one supabase-js picks for itself, so moving an app onto this signs nobody out.
|
|
23
|
+
* On a phone, pass `flowType: "pkce"` as above: a sign-in comes back through a custom scheme any app
|
|
24
|
+
* can claim, and under the implicit flow that URL carries the refresh token itself.
|
|
25
|
+
*/
|
|
26
|
+
export function supabaseAuthStorage(supabaseUrl) {
|
|
27
|
+
return { storage: AsyncStorage, storageKey: supabaseStorageKey(supabaseUrl) };
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=supabase.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"supabase.js","sourceRoot":"","sources":["../src/supabase.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,YAAY,MAAM,2CAA2C,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAA+B,MAAM,yBAAyB,CAAC;AAE1F;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,mBAAmB,CAAC,WAAmB;IACrD,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,kBAAkB,CAAC,WAAW,CAAC,EAAE,CAAC;AAChF,CAAC"}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ResolvedTheme, Theme, ThemeMode, ThemeSnapshot } from "@gusnips/react/theme";
|
|
2
|
+
export type { ResolvedTheme, ThemeMode, ThemeSnapshot };
|
|
3
|
+
export interface NativeThemeOptions {
|
|
4
|
+
/** The AsyncStorage key holding the choice. Keep the key your app already uses. */
|
|
5
|
+
key: string;
|
|
6
|
+
/** What an empty or unrecognised key means. Defaults to `"system"`. */
|
|
7
|
+
defaultMode?: ThemeMode;
|
|
8
|
+
}
|
|
9
|
+
export interface NativeTheme extends Theme {
|
|
10
|
+
/**
|
|
11
|
+
* Load the stored choice, apply it, and keep it applied each time the app comes back to the
|
|
12
|
+
* foreground. Returns the function that stops it, so it fits a root `useEffect` as it is:
|
|
13
|
+
* `useEffect(() => theme.start(), [])`.
|
|
14
|
+
*/
|
|
15
|
+
start(): () => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Nothing runs at import: the snapshot is read on first use, and storage only in `start()`.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* export const theme = createNativeTheme({ key: "app:theme" });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function createNativeTheme({ key, defaultMode, }: NativeThemeOptions): NativeTheme;
|
|
25
|
+
//# sourceMappingURL=theme.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,aAAa,EAAE,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAI3F,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,aAAa,EAAE,CAAC;AAExD,MAAM,WAAW,kBAAkB;IACjC,mFAAmF;IACnF,GAAG,EAAE,MAAM,CAAC;IACZ,uEAAuE;IACvE,WAAW,CAAC,EAAE,SAAS,CAAC;CACzB;AAED,MAAM,WAAW,WAAY,SAAQ,KAAK;IACxC;;;;OAIG;IACH,KAAK,IAAI,MAAM,IAAI,CAAC;CACrB;AAOD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,GAAG,EACH,WAAsB,GACvB,EAAE,kBAAkB,GAAG,WAAW,CA0ElC"}
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light, dark, or whatever the phone says, remembered across launches.
|
|
3
|
+
*
|
|
4
|
+
* The phone's half of `@gusnips/react/theme`: the same three modes, the same snapshot and the same
|
|
5
|
+
* `useTheme`, so a settings screen written against one reads the same against the other. What
|
|
6
|
+
* differs is where it lands. On the web the theme is a class on `<html>`; here it is
|
|
7
|
+
* `Appearance.setColorScheme`, which is what `useColorScheme()` and react-native-css's `dark:`
|
|
8
|
+
* variant both read. `"system"` clears the override, so the OS decides again, live.
|
|
9
|
+
*
|
|
10
|
+
* Two things the donor's store taught:
|
|
11
|
+
*
|
|
12
|
+
* - **Android drops the override when the activity is recreated** under a running JS context (a
|
|
13
|
+
* system overlay, a configuration change), and the app comes back in the OS scheme with the old
|
|
14
|
+
* choice still in memory. So the choice is applied again every time the app returns to the
|
|
15
|
+
* foreground. No storage read for that, only the mode already held.
|
|
16
|
+
* - **A choice made while the stored one is still loading wins.** The read is async. A tap on
|
|
17
|
+
* "Dark" in the first second of a launch must not be undone by the stored "Light" arriving after
|
|
18
|
+
* it, which the donor's store would have done.
|
|
19
|
+
*/
|
|
20
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
21
|
+
import { useSyncExternalStore } from "react";
|
|
22
|
+
import { AppState, Appearance } from "react-native";
|
|
23
|
+
const MODES = ["light", "dark", "system"];
|
|
24
|
+
const isMode = (value) => value !== null && MODES.includes(value);
|
|
25
|
+
/**
|
|
26
|
+
* Nothing runs at import: the snapshot is read on first use, and storage only in `start()`.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* export const theme = createNativeTheme({ key: "app:theme" });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function createNativeTheme({ key, defaultMode = "system", }) {
|
|
33
|
+
let mode = defaultMode;
|
|
34
|
+
let chosen = false;
|
|
35
|
+
let snapshot = null;
|
|
36
|
+
const listeners = new Set();
|
|
37
|
+
let appearance = null;
|
|
38
|
+
const resolved = () => (Appearance.getColorScheme() === "dark" ? "dark" : "light");
|
|
39
|
+
function getSnapshot() {
|
|
40
|
+
snapshot ??= { mode, resolved: resolved() };
|
|
41
|
+
return snapshot;
|
|
42
|
+
}
|
|
43
|
+
// `useSyncExternalStore` compares snapshots by identity, so a new object goes out only when
|
|
44
|
+
// something a screen can see has changed.
|
|
45
|
+
function publish() {
|
|
46
|
+
const next = { mode, resolved: resolved() };
|
|
47
|
+
if (snapshot && snapshot.mode === next.mode && snapshot.resolved === next.resolved)
|
|
48
|
+
return;
|
|
49
|
+
snapshot = next;
|
|
50
|
+
for (const listener of listeners)
|
|
51
|
+
listener();
|
|
52
|
+
}
|
|
53
|
+
// `setColorScheme` updates what `getColorScheme` answers at once, and emits no change event of
|
|
54
|
+
// its own, so this publishes rather than waiting for one.
|
|
55
|
+
function apply() {
|
|
56
|
+
Appearance.setColorScheme(mode === "system" ? "unspecified" : mode);
|
|
57
|
+
publish();
|
|
58
|
+
}
|
|
59
|
+
function setMode(next) {
|
|
60
|
+
chosen = true;
|
|
61
|
+
mode = next;
|
|
62
|
+
apply();
|
|
63
|
+
AsyncStorage.setItem(key, next).catch(() => {
|
|
64
|
+
// Not remembered across launches, and still applied for this one.
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function subscribe(listener) {
|
|
68
|
+
listeners.add(listener);
|
|
69
|
+
// The OS switching scheme (a phone that goes dark at sunset) while the mode is "system".
|
|
70
|
+
appearance ??= Appearance.addChangeListener(publish);
|
|
71
|
+
return () => {
|
|
72
|
+
listeners.delete(listener);
|
|
73
|
+
if (listeners.size === 0) {
|
|
74
|
+
appearance?.remove();
|
|
75
|
+
appearance = null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function start() {
|
|
80
|
+
AsyncStorage.getItem(key).then((stored) => {
|
|
81
|
+
if (chosen)
|
|
82
|
+
return;
|
|
83
|
+
mode = isMode(stored) ? stored : defaultMode;
|
|
84
|
+
apply();
|
|
85
|
+
}, () => {
|
|
86
|
+
// Nothing readable: the default stands, and a choice made now is still applied.
|
|
87
|
+
});
|
|
88
|
+
const foreground = AppState.addEventListener("change", (state) => {
|
|
89
|
+
if (state === "active")
|
|
90
|
+
apply();
|
|
91
|
+
});
|
|
92
|
+
return () => foreground.remove();
|
|
93
|
+
}
|
|
94
|
+
function useTheme() {
|
|
95
|
+
return { ...useSyncExternalStore(subscribe, getSnapshot), setMode };
|
|
96
|
+
}
|
|
97
|
+
return { useTheme, setMode, getSnapshot, start };
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=theme.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"theme.js","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,152 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "What a bare React Native app needs beside @gusnips/react: streams over XHR, React Query on app focus and network, theme and language that remember the choice, the Supabase session storage, the Metro config for a bun monorepo, and the Tailwind to react-native-css build.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Gustavo Salomé",
|
|
8
|
+
"homepage": "https://github.com/gusnips/kit/tree/main/native#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/gusnips/kit/issues"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./theme": {
|
|
18
|
+
"types": "./dist/theme.d.ts",
|
|
19
|
+
"default": "./dist/theme.js"
|
|
20
|
+
},
|
|
21
|
+
"./network": {
|
|
22
|
+
"types": "./dist/network.d.ts",
|
|
23
|
+
"default": "./dist/network.js"
|
|
24
|
+
},
|
|
25
|
+
"./query": {
|
|
26
|
+
"types": "./dist/query.d.ts",
|
|
27
|
+
"default": "./dist/query.js"
|
|
28
|
+
},
|
|
29
|
+
"./i18n": {
|
|
30
|
+
"types": "./dist/i18n.d.ts",
|
|
31
|
+
"default": "./dist/i18n.js"
|
|
32
|
+
},
|
|
33
|
+
"./supabase": {
|
|
34
|
+
"types": "./dist/supabase.d.ts",
|
|
35
|
+
"default": "./dist/supabase.js"
|
|
36
|
+
},
|
|
37
|
+
"./metro": {
|
|
38
|
+
"types": "./dist/node/metro.d.ts",
|
|
39
|
+
"default": "./dist/node/metro.js"
|
|
40
|
+
},
|
|
41
|
+
"./css": {
|
|
42
|
+
"types": "./dist/node/css.d.ts",
|
|
43
|
+
"default": "./dist/node/css.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"bin": {
|
|
47
|
+
"gusnips-native-css": "./dist/node/bin/gusnips-native-css.js"
|
|
48
|
+
},
|
|
49
|
+
"types": "./dist/index.d.ts",
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"src",
|
|
53
|
+
"README.md",
|
|
54
|
+
"LICENSE"
|
|
55
|
+
],
|
|
56
|
+
"sideEffects": false,
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": "^20.19.4 || ^22.13.0 || >=24.3.0"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"keywords": [
|
|
64
|
+
"react-native",
|
|
65
|
+
"metro",
|
|
66
|
+
"tailwind",
|
|
67
|
+
"react-native-css",
|
|
68
|
+
"nativewind",
|
|
69
|
+
"react-query",
|
|
70
|
+
"i18next",
|
|
71
|
+
"supabase",
|
|
72
|
+
"sse"
|
|
73
|
+
],
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
76
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
|
77
|
+
"test": "vitest run --passWithNoTests",
|
|
78
|
+
"test:watch": "vitest",
|
|
79
|
+
"lint": "oxlint -c .oxlintrc.json src",
|
|
80
|
+
"sync:docs": "cp ../LICENSE .",
|
|
81
|
+
"prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build && bun run sync:docs",
|
|
82
|
+
"release:patch": "bun pm version patch && bun publish --access public",
|
|
83
|
+
"release:minor": "bun pm version minor && bun publish --access public",
|
|
84
|
+
"release:major": "bun pm version major && bun publish --access public"
|
|
85
|
+
},
|
|
86
|
+
"peerDependencies": {
|
|
87
|
+
"@gusnips/http": ">=0.2.0 <1",
|
|
88
|
+
"@gusnips/react": ">=0.10.1 <1",
|
|
89
|
+
"@react-native-async-storage/async-storage": ">=3",
|
|
90
|
+
"@react-native-community/netinfo": ">=12",
|
|
91
|
+
"@supabase/supabase-js": "^2",
|
|
92
|
+
"@tailwindcss/cli": "^4",
|
|
93
|
+
"@tanstack/react-query": "^5",
|
|
94
|
+
"i18next": ">=25",
|
|
95
|
+
"react": "^19",
|
|
96
|
+
"react-native": ">=0.86",
|
|
97
|
+
"react-native-css": "^3.0.7",
|
|
98
|
+
"react-native-localize": ">=3",
|
|
99
|
+
"tailwindcss": "^4"
|
|
100
|
+
},
|
|
101
|
+
"peerDependenciesMeta": {
|
|
102
|
+
"@react-native-async-storage/async-storage": {
|
|
103
|
+
"optional": true
|
|
104
|
+
},
|
|
105
|
+
"@react-native-community/netinfo": {
|
|
106
|
+
"optional": true
|
|
107
|
+
},
|
|
108
|
+
"@supabase/supabase-js": {
|
|
109
|
+
"optional": true
|
|
110
|
+
},
|
|
111
|
+
"@tailwindcss/cli": {
|
|
112
|
+
"optional": true
|
|
113
|
+
},
|
|
114
|
+
"@tanstack/react-query": {
|
|
115
|
+
"optional": true
|
|
116
|
+
},
|
|
117
|
+
"i18next": {
|
|
118
|
+
"optional": true
|
|
119
|
+
},
|
|
120
|
+
"react-native-css": {
|
|
121
|
+
"optional": true
|
|
122
|
+
},
|
|
123
|
+
"react-native-localize": {
|
|
124
|
+
"optional": true
|
|
125
|
+
},
|
|
126
|
+
"tailwindcss": {
|
|
127
|
+
"optional": true
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
"devDependencies": {
|
|
131
|
+
"@gusnips/http": ">=0.2.0 <1",
|
|
132
|
+
"@gusnips/react": ">=0.10.0 <1",
|
|
133
|
+
"@react-native-async-storage/async-storage": "^3.1.1",
|
|
134
|
+
"@react-native-community/netinfo": "^12.0.1",
|
|
135
|
+
"@supabase/supabase-js": "^2.113.0",
|
|
136
|
+
"@tanstack/react-query": "^5.102.8",
|
|
137
|
+
"@types/node": "^24.12.0",
|
|
138
|
+
"@types/react": "^19.2.14",
|
|
139
|
+
"i18next": "^26.3.1",
|
|
140
|
+
"react": "^19.2.4",
|
|
141
|
+
"react-native": "0.86.2",
|
|
142
|
+
"react-native-css": "3.0.7",
|
|
143
|
+
"react-native-localize": "^3.7.0",
|
|
144
|
+
"typescript": "^7.0.2",
|
|
145
|
+
"vitest": "^4.1.2"
|
|
146
|
+
},
|
|
147
|
+
"repository": {
|
|
148
|
+
"type": "git",
|
|
149
|
+
"url": "git+https://github.com/gusnips/kit.git",
|
|
150
|
+
"directory": "native"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pickDeviceLocale } from "./device-locale.ts";
|
|
3
|
+
|
|
4
|
+
// The same table `matchLocale` in @gusnips/locale is held to, so a phone and a browser with the
|
|
5
|
+
// same languages land on the same one.
|
|
6
|
+
describe("pickDeviceLocale", () => {
|
|
7
|
+
const SHIPPED = ["en", "pt-BR", "es"];
|
|
8
|
+
|
|
9
|
+
it("walks the phone's list in its own order", () => {
|
|
10
|
+
expect(pickDeviceLocale(["es", "en"], SHIPPED)).toBe("es");
|
|
11
|
+
// i18next's matcher answers English here: an exact match anywhere beats a base subtag.
|
|
12
|
+
expect(pickDeviceLocale(["pt-PT", "en"], SHIPPED)).toBe("pt-BR");
|
|
13
|
+
expect(pickDeviceLocale(["pt"], SHIPPED)).toBe("pt-BR");
|
|
14
|
+
expect(pickDeviceLocale(["PT-br"], SHIPPED)).toBe("pt-BR");
|
|
15
|
+
expect(pickDeviceLocale(["de", "es-MX"], SHIPPED)).toBe("es");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("looks past the first language, which the donor did not", () => {
|
|
19
|
+
expect(pickDeviceLocale(["fr-FR", "en-US"], SHIPPED)).toBe("en");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("answers null when nothing matches, so i18next's fallback applies", () => {
|
|
23
|
+
expect(pickDeviceLocale(["de", "fr"], SHIPPED)).toBeNull();
|
|
24
|
+
expect(pickDeviceLocale(["", " "], SHIPPED)).toBeNull();
|
|
25
|
+
expect(pickDeviceLocale([], SHIPPED)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("takes an exact tag over an earlier language that only shares its base", () => {
|
|
29
|
+
const both = ["pt-BR", "pt-PT"];
|
|
30
|
+
expect(pickDeviceLocale(["pt-PT"], both)).toBe("pt-PT");
|
|
31
|
+
expect(pickDeviceLocale(["pt"], both)).toBe("pt-BR");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language we ship that the phone's list asks for first, or `null` when none of them is one.
|
|
3
|
+
*
|
|
4
|
+
* Walk the phone's list in ITS order, and for each tag take an exact match (any case), then a
|
|
5
|
+
* language with the same base subtag. So a phone set to `["pt-PT", "en-US"]` gets our `pt-BR`: the
|
|
6
|
+
* person put Portuguese first. The donor looked at the first tag only, so a phone on
|
|
7
|
+
* `["fr-FR", "en-US"]` got the fallback instead of the English the person also reads.
|
|
8
|
+
*
|
|
9
|
+
* This is `matchLocale` from `@gusnips/locale`, written again because that package is a leaf
|
|
10
|
+
* nothing in this repo depends on (AGENTS.md says why). `@gusnips/react`'s `i18nInitOptions` keeps
|
|
11
|
+
* a third copy. The test holds this one to the same table, so a reader lands on one language
|
|
12
|
+
* whichever road they come in by.
|
|
13
|
+
*/
|
|
14
|
+
export function pickDeviceLocale(
|
|
15
|
+
deviceTags: readonly string[],
|
|
16
|
+
supported: readonly string[],
|
|
17
|
+
): string | null {
|
|
18
|
+
for (const tag of deviceTags) {
|
|
19
|
+
const lower = tag.trim().toLowerCase();
|
|
20
|
+
if (lower === "") continue;
|
|
21
|
+
const base = lower.split("-")[0];
|
|
22
|
+
const found =
|
|
23
|
+
supported.find((lng) => lng.toLowerCase() === lower) ??
|
|
24
|
+
supported.find((lng) => lng.split("-")[0]?.toLowerCase() === base);
|
|
25
|
+
if (found !== undefined) return found;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's language on a phone: the one the person picked, else the phone's own, else your
|
|
3
|
+
* fallback.
|
|
4
|
+
*
|
|
5
|
+
* The detector reads the phone's languages with react-native-localize, synchronously, so the first
|
|
6
|
+
* render is already in a real language. A choice saved earlier is read from AsyncStorage after
|
|
7
|
+
* that (`restore`), and wins.
|
|
8
|
+
*
|
|
9
|
+
* **Only `choose` writes the saved choice.** i18next calls a detector's `cacheUserLanguage` on
|
|
10
|
+
* EVERY language change, the one it detected at launch included. The donor's detector saved there,
|
|
11
|
+
* so the first launch saved the phone's language as though the person had picked it, and when they
|
|
12
|
+
* later switched the phone to another language the app stayed in the old one for good. That is
|
|
13
|
+
* `storageWriter: "app"` in `@gusnips/react`'s `i18nInitOptions`, the same lesson from the web: one
|
|
14
|
+
* writer per key, and here the writer is your language control.
|
|
15
|
+
*/
|
|
16
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
17
|
+
import type { i18n as I18n, LanguageDetectorModule } from "i18next";
|
|
18
|
+
import { getLocales } from "react-native-localize";
|
|
19
|
+
import { pickDeviceLocale } from "./device-locale.ts";
|
|
20
|
+
|
|
21
|
+
export interface NativeLanguageOptions {
|
|
22
|
+
/** Every language this app ships, as the exact tags its catalogs use. */
|
|
23
|
+
supported: readonly string[];
|
|
24
|
+
/** The AsyncStorage key holding the person's choice. Keep the key your app already uses. */
|
|
25
|
+
storageKey: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NativeLanguage {
|
|
29
|
+
/** Hand to `i18n.use()` before `init`. */
|
|
30
|
+
detector: LanguageDetectorModule;
|
|
31
|
+
/** Apply the saved choice, if there is one. Call once at launch, after `init`. */
|
|
32
|
+
restore(): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* The person picked a language: switch to it and remember it. `null` means "follow the phone":
|
|
35
|
+
* forget the choice and switch to what the phone asks for.
|
|
36
|
+
*/
|
|
37
|
+
choose(language: string | null): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ```ts
|
|
42
|
+
* import i18n from "i18next";
|
|
43
|
+
*
|
|
44
|
+
* export const language = createNativeLanguage(i18n, {
|
|
45
|
+
* supported: ["pt-BR", "en"],
|
|
46
|
+
* storageKey: "app:language",
|
|
47
|
+
* });
|
|
48
|
+
* i18n.use(language.detector).use(initReactI18next).init({ resources, fallbackLng: "pt-BR" });
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* A phone whose languages are none of `supported` gets `fallbackLng`: the detector answers nothing
|
|
52
|
+
* and i18next falls back on its own.
|
|
53
|
+
*/
|
|
54
|
+
export function createNativeLanguage(i18n: I18n, options: NativeLanguageOptions): NativeLanguage {
|
|
55
|
+
const { supported, storageKey } = options;
|
|
56
|
+
// A choice made while the saved one is still loading wins over it.
|
|
57
|
+
let chosen = false;
|
|
58
|
+
|
|
59
|
+
const fromDevice = () =>
|
|
60
|
+
pickDeviceLocale(
|
|
61
|
+
getLocales().map((locale) => locale.languageTag),
|
|
62
|
+
supported,
|
|
63
|
+
) ?? undefined;
|
|
64
|
+
|
|
65
|
+
const detector: LanguageDetectorModule = {
|
|
66
|
+
type: "languageDetector",
|
|
67
|
+
init: () => {},
|
|
68
|
+
detect: fromDevice,
|
|
69
|
+
// Deliberately empty. See the top of this file.
|
|
70
|
+
cacheUserLanguage: () => {},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
async function restore(): Promise<void> {
|
|
74
|
+
const stored = await AsyncStorage.getItem(storageKey).catch(() => null);
|
|
75
|
+
if (chosen || stored === null || !supported.includes(stored)) return;
|
|
76
|
+
if (i18n.language !== stored) await i18n.changeLanguage(stored);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function choose(language: string | null): Promise<void> {
|
|
80
|
+
chosen = true;
|
|
81
|
+
const write =
|
|
82
|
+
language === null
|
|
83
|
+
? AsyncStorage.removeItem(storageKey)
|
|
84
|
+
: AsyncStorage.setItem(storageKey, language);
|
|
85
|
+
// Not remembered across launches when storage fails, and still applied for this one.
|
|
86
|
+
await write.catch(() => {});
|
|
87
|
+
await i18n.changeLanguage(language ?? fromDevice());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { detector, restore, choose };
|
|
91
|
+
}
|