@gusnips/native 0.0.0-stage → 0.1.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -2
  3. package/dist/device-locale.d.ts +15 -0
  4. package/dist/device-locale.d.ts.map +1 -0
  5. package/dist/device-locale.js +27 -0
  6. package/dist/device-locale.js.map +1 -0
  7. package/dist/i18n.d.ts +34 -0
  8. package/dist/i18n.d.ts.map +1 -0
  9. package/dist/i18n.js +63 -0
  10. package/dist/i18n.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +18 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/network.d.ts +18 -0
  16. package/dist/network.d.ts.map +1 -0
  17. package/dist/network.js +35 -0
  18. package/dist/network.js.map +1 -0
  19. package/dist/node/bin/gusnips-native-css.d.ts +3 -0
  20. package/dist/node/bin/gusnips-native-css.d.ts.map +1 -0
  21. package/dist/node/bin/gusnips-native-css.js +75 -0
  22. package/dist/node/bin/gusnips-native-css.js.map +1 -0
  23. package/dist/node/css.d.ts +50 -0
  24. package/dist/node/css.d.ts.map +1 -0
  25. package/dist/node/css.js +195 -0
  26. package/dist/node/css.js.map +1 -0
  27. package/dist/node/metro.d.ts +51 -0
  28. package/dist/node/metro.d.ts.map +1 -0
  29. package/dist/node/metro.js +100 -0
  30. package/dist/node/metro.js.map +1 -0
  31. package/dist/query.d.ts +13 -0
  32. package/dist/query.d.ts.map +1 -0
  33. package/dist/query.js +37 -0
  34. package/dist/query.js.map +1 -0
  35. package/dist/sse.d.ts +47 -0
  36. package/dist/sse.d.ts.map +1 -0
  37. package/dist/sse.js +135 -0
  38. package/dist/sse.js.map +1 -0
  39. package/dist/supabase.d.ts +18 -0
  40. package/dist/supabase.d.ts.map +1 -0
  41. package/dist/supabase.js +29 -0
  42. package/dist/supabase.js.map +1 -0
  43. package/dist/theme.d.ts +25 -0
  44. package/dist/theme.d.ts.map +1 -0
  45. package/dist/theme.js +99 -0
  46. package/dist/theme.js.map +1 -0
  47. package/package.json +150 -4
  48. package/src/device-locale.test.ts +33 -0
  49. package/src/device-locale.ts +28 -0
  50. package/src/i18n.ts +91 -0
  51. package/src/index.ts +18 -0
  52. package/src/network.ts +37 -0
  53. package/src/node/bin/gusnips-native-css.ts +84 -0
  54. package/src/node/css.test.ts +19 -0
  55. package/src/node/css.ts +259 -0
  56. package/src/node/metro.test.ts +66 -0
  57. package/src/node/metro.ts +143 -0
  58. package/src/query.ts +41 -0
  59. package/src/sse.test.ts +195 -0
  60. package/src/sse.ts +174 -0
  61. package/src/supabase.ts +29 -0
  62. package/src/theme.test.ts +92 -0
  63. package/src/theme.ts +132 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,312 @@
1
- # Temporary Holding Version
1
+ # @gusnips/native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ What a bare React Native app needs beside `@gusnips/react`: the Metro config for a bun monorepo,
4
+ the Tailwind build for react-native-css, streams from your API, React Query that knows when the app
5
+ is in the background, and a theme and a language that remember what the person picked. No Expo.
6
+
7
+ ```bash
8
+ bun add @gusnips/native
9
+ ```
10
+
11
+ ```js
12
+ // metro.config.js
13
+ const { getDefaultConfig } = require("@react-native/metro-config");
14
+ const { nativeMetroConfig } = require("@gusnips/native/metro");
15
+
16
+ module.exports = nativeMetroConfig(getDefaultConfig(__dirname));
17
+ ```
18
+
19
+ That one line makes every `react` import in the bundle the app's own copy, and maps
20
+ react-native-css's override module so styled components load. Both are crashes you would otherwise
21
+ meet at launch.
22
+
23
+ ## What is in it
24
+
25
+ | Import | What it does | Also needs |
26
+ | -------------------------- | -------------------------------------------------------------- | ----------------------------------------------------- |
27
+ | `@gusnips/native` | `openSseStream`: server-sent events over XHR | nothing more |
28
+ | `@gusnips/native/theme` | `createNativeTheme`: light, dark or system, remembered | `@react-native-async-storage/async-storage` |
29
+ | `@gusnips/native/network` | `useIsOffline`, `isDeviceOffline` | `@react-native-community/netinfo` |
30
+ | `@gusnips/native/query` | `installQueryManagers`: React Query on app focus and network | `@tanstack/react-query`, netinfo |
31
+ | `@gusnips/native/i18n` | `createNativeLanguage`: the phone's language, or the saved one | `react-native-localize`, async-storage, `i18next` |
32
+ | `@gusnips/native/supabase` | `supabaseAuthStorage`: where supabase-js keeps the session | async-storage, `@supabase/supabase-js` |
33
+ | `@gusnips/native/metro` | `nativeMetroConfig`, for `metro.config.js` | nothing more |
34
+ | `@gusnips/native/css` | `buildNativeCss` and the `gusnips-native-css` command | `@tailwindcss/cli`, `tailwindcss`, `react-native-css` |
35
+
36
+ Every app needs `react`, `react-native`, `@gusnips/react` and `@gusnips/http`. The rest are
37
+ optional: you install one only when you import the line that uses it. Most of them carry native
38
+ code that has to be linked into the app, so an app that does not use NetInfo should not have to
39
+ ship it.
40
+
41
+ ## Metro
42
+
43
+ In a monorepo, tell it where the root is:
44
+
45
+ ```js
46
+ module.exports = nativeMetroConfig(getDefaultConfig(__dirname), {
47
+ monorepoRoot: require("node:path").resolve(__dirname, "../.."),
48
+ });
49
+ ```
50
+
51
+ What it adds to the config:
52
+
53
+ - **The monorepo.** `watchFolders` and `nodeModulesPaths` point at the root, so a workspace package
54
+ that ships TypeScript source resolves like any other. Metro does not find workspaces on its own.
55
+ - **One React.** bun's isolated store keeps a spare `react` at its root. A package that forgets to
56
+ declare react as a peer finds that one instead, and the bundle has two: your screens render, and
57
+ that package's hooks crash with "Cannot read property 'useMemo' of null". Every `react` and
58
+ `react/*` import now goes to the copy your app resolves. `react-native` and `react-dom` are left
59
+ alone.
60
+ - **react-native-css's override module.** `react-native-css/native` imports a module that throws
61
+ unless Metro maps it to the package's empty `override.js`. Without the mapping every styled
62
+ component fails with "Cannot read property 'useCssElement' of undefined". Skipped when the app
63
+ does not use react-native-css.
64
+
65
+ A resolver already on the config still runs for everything else.
66
+
67
+ **`metro.config.js` stays CommonJS, as the React Native template writes it.** This package is ES
68
+ modules only, and `require()` of an ES module works without a flag on every Node version React
69
+ Native 0.86 supports: 20.19.4 and later, 22.13 and later, 24.3 and later. Measured by loading the
70
+ config through Metro's own `loadConfig` on Node 22.22 and 24.21.
71
+
72
+ Never add `withReactNativeCSS` or NativeWind's `withNativewind` on top. Both also replace Metro's
73
+ transformer with one that requires `@expo/metro-config`, and Metro crashes on a bare app. The CSS
74
+ is built ahead of time instead, below.
75
+
76
+ ## Styles: Tailwind on React Native
77
+
78
+ NativeWind v5 and react-native-css read `className` at runtime against a compiled stylesheet.
79
+ `gusnips-native-css` builds that stylesheet from your `global.css`, with the Tailwind v4 CLI and
80
+ then react-native-css's compiler:
81
+
82
+ ```bash
83
+ gusnips-native-css # global.css → src/generated/stylesheet.ts
84
+ ```
85
+
86
+ Run it before Metro, in the same script, or a class you just added resolves to nothing:
87
+
88
+ ```json
89
+ {
90
+ "scripts": {
91
+ "start": "gusnips-native-css && react-native start",
92
+ "ios": "gusnips-native-css && react-native run-ios",
93
+ "android": "gusnips-native-css && react-native run-android"
94
+ }
95
+ }
96
+ ```
97
+
98
+ Then load the stylesheet once, at the top of `App.tsx`:
99
+
100
+ ```ts
101
+ import { StyleCollection } from "react-native-css/native-internal";
102
+ import { stylesheet } from "./generated/stylesheet";
103
+
104
+ StyleCollection.inject(stylesheet);
105
+ ```
106
+
107
+ Never import `global.css` from `index.js`: NativeWind warns that it breaks Fast Refresh, and the
108
+ compiled file is what the app reads. Add `src/generated/` to `.gitignore`.
109
+
110
+ The tools come from your app, not from this package, because the stylesheet must match the
111
+ react-native-css your bundle runs:
112
+
113
+ ```bash
114
+ bun add react-native-css && bun add -d @tailwindcss/cli tailwindcss
115
+ ```
116
+
117
+ Five things worth knowing:
118
+
119
+ - **One `rem` is 16 pixels, as on the web.** react-native-css's own default is 14, React Native's
120
+ root font size, which shrinks every rem-based size by an eighth: `h-12` comes out 42 px instead
121
+ of 48. `--rem` changes it if you really want another value.
122
+ - **Tailwind finds your classes by itself** in the app's folder. Tailwind v4 has no `--content`
123
+ flag. For a file outside the folder, add an `@source` line to `global.css`.
124
+ - **Pin lightningcss to exactly 1.30.1.** react-native-css 3.0.7's compiler breaks on 1.30.2, and
125
+ an app measured 1.32.0 and 1.33.0 breaking it too, so no later release is known to work.
126
+ react-native-css's own check is swallowed by an empty `catch`, so the build refuses every
127
+ release after 1.30.1 instead. Pin it with `"overrides": { "lightningcss": "1.30.1" }` in the root
128
+ `package.json`: a devDependency is not enough, because react-native-css loads the copy under
129
+ `@expo/metro-config` first.
130
+ - **A `className` change hot-reloads.** Only a change to `global.css` or your tokens needs the
131
+ build to run again.
132
+ - **There is no global font.** react-native-css drops element and `*` selectors, so a text without
133
+ a `font-*` class renders in the system font.
134
+
135
+ ### Colours for code
136
+
137
+ An icon's `color`, the navigation theme and the status bar take a hex string, not a class. Name
138
+ the colours in a JSON file, each as a class per scheme:
139
+
140
+ ```json
141
+ {
142
+ "text": { "light": "text-ink", "dark": "dark:text-ink-dark" },
143
+ "canvas": { "light": "bg-surface", "dark": "dark:bg-surface-dark" }
144
+ }
145
+ ```
146
+
147
+ `gusnips-native-css --palette palette.json` writes `colors.ts` beside the stylesheet, with the hex
148
+ each class compiles to. The colours come from the stylesheet itself, so a change of tokens moves the
149
+ icons with the text. Tailwind writes only classes it finds in your files, so keep each palette
150
+ class in your source; the build stops and names any it cannot find.
151
+
152
+ From a script, the same build is `buildNativeCss({ palette })` from `@gusnips/native/css`.
153
+
154
+ ## Streams
155
+
156
+ React Native's `fetch` cannot read a response as it arrives, so `readSseStream` from
157
+ `@gusnips/react` has nothing to read. `openSseStream` uses `XMLHttpRequest`, which can, and the
158
+ same frame parser:
159
+
160
+ ```ts
161
+ import { openSseStream } from "@gusnips/native";
162
+
163
+ const stream = openSseStream({
164
+ url: `${API_URL}/chat/stream`,
165
+ body: { message },
166
+ session, // the SessionAdapter your createApiClient uses
167
+ onFrame: ({ event, data }) => handle(event, JSON.parse(data)),
168
+ onError: showError,
169
+ });
170
+
171
+ stream.abort(); // when the screen closes
172
+ ```
173
+
174
+ - A refused stream fails with the same `ApiError` your API client throws, with the code, the
175
+ request id and the `Retry-After` wait. So a 402 on a stream gets the same sentence and button as
176
+ any other 402. A lost connection is a plain `Error`, which the describer reads as "never
177
+ landed".
178
+ - It sends the session's token, and on a 401 it calls the session's `refresh()` once and opens
179
+ again. Pass the same adapter your API client uses: its refresh runs once for everyone, so a
180
+ stream and a page load do not refresh at the same time (the auth server rotates the refresh
181
+ token, and the loser of that race signs the person out). No token back, and the stream fails
182
+ with the 401; ending the session stays the API client's job. `headers` adds anything else, like
183
+ the client's own option.
184
+ - `data` is raw text. Parse it the way your server writes it.
185
+ - No reconnect. Reopen the stream from where your screen knows how to recover.
186
+ - The whole response stays in memory until the stream ends. Fine for a chat answer; not for a feed
187
+ left open all day.
188
+
189
+ ## React Query
190
+
191
+ ```ts
192
+ import { queryDefaults } from "@gusnips/react";
193
+ import { installQueryManagers } from "@gusnips/native/query";
194
+
195
+ installQueryManagers();
196
+ export const queryClient = new QueryClient({ defaultOptions: queryDefaults() });
197
+ ```
198
+
199
+ React Query's own defaults watch the browser's window and `navigator.onLine`, which a phone does
200
+ not have. After this, retries wait while the app is in the background or offline, and stale
201
+ queries run again when the connection returns.
202
+
203
+ It does **not** refetch when the app comes back to the foreground: `queryDefaults` leaves
204
+ `refetchOnWindowFocus` off. For an app people leave open for days, you probably want it on:
205
+ `queryDefaults({ refetchOnWindowFocus: true })`.
206
+
207
+ ## Offline
208
+
209
+ ```ts
210
+ import { useIsOffline } from "@gusnips/native/network";
211
+
212
+ const offline = useIsOffline(); // true while the phone has no connection
213
+ ```
214
+
215
+ `isDeviceOffline()` answers the same question once. React Native's `fetch` fails the same way
216
+ whether the server is down or the phone has no signal, so ask it before you tell someone the
217
+ service is having trouble. While NetInfo is still working it out, both say "online", so no
218
+ offline banner flashes at launch.
219
+
220
+ ## Theme
221
+
222
+ The same options and the same `useTheme` as `@gusnips/react/theme`, applied with
223
+ `Appearance.setColorScheme`, which is what `useColorScheme()` and the `dark:` classes read:
224
+
225
+ ```ts
226
+ import { createNativeTheme } from "@gusnips/native/theme";
227
+
228
+ export const theme = createNativeTheme({ key: "app:theme" });
229
+
230
+ // In the root component:
231
+ useEffect(() => theme.start(), []);
232
+
233
+ // Anywhere:
234
+ const { mode, resolved, setMode } = theme.useTheme();
235
+ ```
236
+
237
+ `start()` loads the saved choice and puts it back each time the app returns to the foreground,
238
+ because Android drops the choice when it recreates the screen. A choice made while the saved one is
239
+ still loading wins over it.
240
+
241
+ ## Language
242
+
243
+ ```ts
244
+ import i18n from "i18next";
245
+ import { initReactI18next } from "react-i18next";
246
+ import { createNativeLanguage } from "@gusnips/native/i18n";
247
+
248
+ export const language = createNativeLanguage(i18n, {
249
+ supported: ["pt-BR", "en"],
250
+ storageKey: "app:language",
251
+ });
252
+
253
+ i18n.use(language.detector).use(initReactI18next).init({ resources, fallbackLng: "pt-BR" });
254
+ ```
255
+
256
+ At launch, call `language.restore()`. From your language picker, call `language.choose("en")`, or
257
+ `language.choose(null)` to follow the phone again.
258
+
259
+ - The first render is already in the phone's language. The saved choice is read after, and wins.
260
+ - The phone's languages are read in its order: on `pt-PT, en-US` the app picks `pt-BR`, and on
261
+ `fr-FR, en-US` it picks `en` instead of the fallback.
262
+ - Only `choose` saves a language. i18next hands every language change to the detector, including
263
+ the one it detects at launch, so a detector that saved there would treat the phone's language as
264
+ the person's choice, and the app would ignore the phone from then on.
265
+
266
+ ## Supabase
267
+
268
+ ```ts
269
+ import { supabaseAuthStorage } from "@gusnips/native/supabase";
270
+
271
+ export const authStorage = supabaseAuthStorage(SUPABASE_URL);
272
+ export const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
273
+ auth: { ...authStorage, detectSessionInUrl: false, flowType: "pkce" },
274
+ });
275
+
276
+ await signOutEvenOffline(supabase.auth, authStorage); // from @gusnips/react/supabase
277
+ ```
278
+
279
+ The session goes in AsyncStorage, not the keychain: a session is two tokens and the user's data in
280
+ one value, and it often passes the about 2 KB a keystore holds per value. The key is the one
281
+ supabase-js picks itself, so moving onto this signs nobody out. Pass the same object to
282
+ `signOutEvenOffline`, so the sign-out clears the key the client reads.
283
+
284
+ Use `flowType: "pkce"`. A sign-in returns through your app's URL scheme, which any app can claim,
285
+ and under the default flow that URL carries the refresh token.
286
+
287
+ ## Also worth knowing
288
+
289
+ - **Do not use `react-native-css/components/react-native-safe-area-context`.** In 3.0.7 everything
290
+ under it loses its `className` styles. Use `SafeAreaView` from `react-native-safe-area-context`
291
+ with `style={{ flex: 1 }}`, and put the colours on a styled `View` inside it.
292
+ - **`nativewind/babel` is a preset, not a plugin.** Put it in `presets` in `babel.config.js`.
293
+ - **Keep React at one version in the whole repo**, with the root `workspaces.catalog`. The Metro
294
+ resolver protects the bundle from bun's store layout; the catalog stops the versions drifting in
295
+ the first place.
296
+
297
+ ## Why the dependencies are peers
298
+
299
+ There are no `dependencies`. Each package here is one your app already has, or one that carries
300
+ native code, and an app must have exactly one copy of each:
301
+
302
+ - `react`, `react-native`: two copies of either breaks the app.
303
+ - `@gusnips/react` is a peer, not a dependency, on purpose. `@gusnips/vite` depends on it, so its
304
+ release pins one exact version, and an app on another version gets a second copy nested inside
305
+ vite. A peer uses yours. Its range is `>=0.10.0 <1`, so a new minor of `@gusnips/react` does not
306
+ need a release of this package.
307
+ - `@gusnips/http`: `openSseStream` reads the error envelope and `Retry-After` with it, the same
308
+ functions the API client uses. Same reasoning, same kind of range.
309
+ - AsyncStorage, NetInfo and react-native-localize hold native code that autolinking compiles into
310
+ the app, so each is optional and behind its own import.
311
+ - `tailwindcss`, `@tailwindcss/cli` and `react-native-css` are read from your app's folder at build
312
+ time, so the stylesheet matches what your bundle runs.
@@ -0,0 +1,15 @@
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 declare function pickDeviceLocale(deviceTags: readonly string[], supported: readonly string[]): string | null;
15
+ //# sourceMappingURL=device-locale.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-locale.d.ts","sourceRoot":"","sources":["../src/device-locale.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,MAAM,GAAG,IAAI,CAWf"}
@@ -0,0 +1,27 @@
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(deviceTags, supported) {
15
+ for (const tag of deviceTags) {
16
+ const lower = tag.trim().toLowerCase();
17
+ if (lower === "")
18
+ continue;
19
+ const base = lower.split("-")[0];
20
+ const found = supported.find((lng) => lng.toLowerCase() === lower) ??
21
+ supported.find((lng) => lng.split("-")[0]?.toLowerCase() === base);
22
+ if (found !== undefined)
23
+ return found;
24
+ }
25
+ return null;
26
+ }
27
+ //# sourceMappingURL=device-locale.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-locale.js","sourceRoot":"","sources":["../src/device-locale.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,gBAAgB,CAC9B,UAA6B,EAC7B,SAA4B;IAE5B,KAAK,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;QAC7B,MAAM,KAAK,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;QACvC,IAAI,KAAK,KAAK,EAAE;YAAE,SAAS;QAC3B,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACjC,MAAM,KAAK,GACT,SAAS,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,WAAW,EAAE,KAAK,KAAK,CAAC;YACpD,SAAS,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,IAAI,CAAC,CAAC;QACrE,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,KAAK,CAAC;IACxC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { i18n as I18n, LanguageDetectorModule } from "i18next";
2
+ export interface NativeLanguageOptions {
3
+ /** Every language this app ships, as the exact tags its catalogs use. */
4
+ supported: readonly string[];
5
+ /** The AsyncStorage key holding the person's choice. Keep the key your app already uses. */
6
+ storageKey: string;
7
+ }
8
+ export interface NativeLanguage {
9
+ /** Hand to `i18n.use()` before `init`. */
10
+ detector: LanguageDetectorModule;
11
+ /** Apply the saved choice, if there is one. Call once at launch, after `init`. */
12
+ restore(): Promise<void>;
13
+ /**
14
+ * The person picked a language: switch to it and remember it. `null` means "follow the phone":
15
+ * forget the choice and switch to what the phone asks for.
16
+ */
17
+ choose(language: string | null): Promise<void>;
18
+ }
19
+ /**
20
+ * ```ts
21
+ * import i18n from "i18next";
22
+ *
23
+ * export const language = createNativeLanguage(i18n, {
24
+ * supported: ["pt-BR", "en"],
25
+ * storageKey: "app:language",
26
+ * });
27
+ * i18n.use(language.detector).use(initReactI18next).init({ resources, fallbackLng: "pt-BR" });
28
+ * ```
29
+ *
30
+ * A phone whose languages are none of `supported` gets `fallbackLng`: the detector answers nothing
31
+ * and i18next falls back on its own.
32
+ */
33
+ export declare function createNativeLanguage(i18n: I18n, options: NativeLanguageOptions): NativeLanguage;
34
+ //# sourceMappingURL=i18n.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,IAAI,IAAI,IAAI,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC;AAIpE,MAAM,WAAW,qBAAqB;IACpC,yEAAyE;IACzE,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,4FAA4F;IAC5F,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,0CAA0C;IAC1C,QAAQ,EAAE,sBAAsB,CAAC;IACjC,kFAAkF;IAClF,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB;;;OAGG;IACH,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,qBAAqB,GAAG,cAAc,CAqC/F"}
package/dist/i18n.js ADDED
@@ -0,0 +1,63 @@
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 { getLocales } from "react-native-localize";
18
+ import { pickDeviceLocale } from "./device-locale.js";
19
+ /**
20
+ * ```ts
21
+ * import i18n from "i18next";
22
+ *
23
+ * export const language = createNativeLanguage(i18n, {
24
+ * supported: ["pt-BR", "en"],
25
+ * storageKey: "app:language",
26
+ * });
27
+ * i18n.use(language.detector).use(initReactI18next).init({ resources, fallbackLng: "pt-BR" });
28
+ * ```
29
+ *
30
+ * A phone whose languages are none of `supported` gets `fallbackLng`: the detector answers nothing
31
+ * and i18next falls back on its own.
32
+ */
33
+ export function createNativeLanguage(i18n, options) {
34
+ const { supported, storageKey } = options;
35
+ // A choice made while the saved one is still loading wins over it.
36
+ let chosen = false;
37
+ const fromDevice = () => pickDeviceLocale(getLocales().map((locale) => locale.languageTag), supported) ?? undefined;
38
+ const detector = {
39
+ type: "languageDetector",
40
+ init: () => { },
41
+ detect: fromDevice,
42
+ // Deliberately empty. See the top of this file.
43
+ cacheUserLanguage: () => { },
44
+ };
45
+ async function restore() {
46
+ const stored = await AsyncStorage.getItem(storageKey).catch(() => null);
47
+ if (chosen || stored === null || !supported.includes(stored))
48
+ return;
49
+ if (i18n.language !== stored)
50
+ await i18n.changeLanguage(stored);
51
+ }
52
+ async function choose(language) {
53
+ chosen = true;
54
+ const write = language === null
55
+ ? AsyncStorage.removeItem(storageKey)
56
+ : AsyncStorage.setItem(storageKey, language);
57
+ // Not remembered across launches when storage fails, and still applied for this one.
58
+ await write.catch(() => { });
59
+ await i18n.changeLanguage(language ?? fromDevice());
60
+ }
61
+ return { detector, restore, choose };
62
+ }
63
+ //# sourceMappingURL=i18n.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,YAAY,MAAM,2CAA2C,CAAC;AAErE,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAqBtD;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAU,EAAE,OAA8B;IAC7E,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAC1C,mEAAmE;IACnE,IAAI,MAAM,GAAG,KAAK,CAAC;IAEnB,MAAM,UAAU,GAAG,GAAG,EAAE,CACtB,gBAAgB,CACd,UAAU,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,WAAW,CAAC,EAChD,SAAS,CACV,IAAI,SAAS,CAAC;IAEjB,MAAM,QAAQ,GAA2B;QACvC,IAAI,EAAE,kBAAkB;QACxB,IAAI,EAAE,GAAG,EAAE,GAAE,CAAC;QACd,MAAM,EAAE,UAAU;QAClB,gDAAgD;QAChD,iBAAiB,EAAE,GAAG,EAAE,GAAE,CAAC;KAC5B,CAAC;IAEF,KAAK,UAAU,OAAO;QACpB,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;QACxE,IAAI,MAAM,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC;YAAE,OAAO;QACrE,IAAI,IAAI,CAAC,QAAQ,KAAK,MAAM;YAAE,MAAM,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;IAClE,CAAC;IAED,KAAK,UAAU,MAAM,CAAC,QAAuB;QAC3C,MAAM,GAAG,IAAI,CAAC;QACd,MAAM,KAAK,GACT,QAAQ,KAAK,IAAI;YACf,CAAC,CAAC,YAAY,CAAC,UAAU,CAAC,UAAU,CAAC;YACrC,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;QACjD,qFAAqF;QACrF,MAAM,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAC5B,MAAM,IAAI,CAAC,cAAc,CAAC,QAAQ,IAAI,UAAU,EAAE,CAAC,CAAC;IACtD,CAAC;IAED,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;AACvC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { openSseStream, type SseStream, type SseStreamOptions } from "./sse.ts";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,aAAa,EAAE,KAAK,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ // @gusnips/native: what a bare React Native app needs beside @gusnips/react.
2
+ //
3
+ // The rule that picks the subpaths is the kit's invariant 15, checked by `bun run exports`: a peer
4
+ // marked optional must not be reachable from this entry. Every subpath below needs one more package
5
+ // with native code or a build tool in it, and an app installs that package only by importing the
6
+ // thing that uses it:
7
+ //
8
+ // @gusnips/native/theme createNativeTheme → @react-native-async-storage/async-storage
9
+ // @gusnips/native/network useIsOffline → @react-native-community/netinfo
10
+ // @gusnips/native/query installQueryManagers → @tanstack/react-query, netinfo
11
+ // @gusnips/native/i18n createNativeLanguage → react-native-localize, async-storage
12
+ // @gusnips/native/supabase supabaseAuthStorage → async-storage, @supabase/supabase-js
13
+ // @gusnips/native/metro nativeMetroConfig → Node, at bundle time
14
+ // @gusnips/native/css buildNativeCss → Node, tailwindcss, react-native-css
15
+ //
16
+ // What is left here needs only the required peers.
17
+ export { openSseStream } from "./sse.js";
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,EAAE;AACF,mGAAmG;AACnG,oGAAoG;AACpG,iGAAiG;AACjG,sBAAsB;AACtB,EAAE;AACF,mGAAmG;AACnG,yFAAyF;AACzF,wFAAwF;AACxF,8FAA8F;AAC9F,8FAA8F;AAC9F,8EAA8E;AAC9E,6FAA6F;AAC7F,EAAE;AACF,mDAAmD;AAEnD,OAAO,EAAE,aAAa,EAAyC,MAAM,UAAU,CAAC"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * True while the phone has no connection.
3
+ *
4
+ * React Query already pauses on its own once `installQueryManagers` from `@gusnips/native/query`
5
+ * is running. This is for a screen that should SAY it is offline instead of showing old data as
6
+ * if it were current.
7
+ */
8
+ export declare function useIsOffline(): boolean;
9
+ /**
10
+ * Was that failure the phone, or the server?
11
+ *
12
+ * React Native's `fetch` rejects with the same bare `TypeError` when the server is down and when
13
+ * the phone has no signal. Ask this before telling somebody the service is having trouble: only a
14
+ * `false` here means the service is to blame. A NetInfo that cannot answer counts as online, so
15
+ * the doubt goes to the message that has a retry beside it.
16
+ */
17
+ export declare function isDeviceOffline(): Promise<boolean>;
18
+ //# sourceMappingURL=network.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../src/network.ts"],"names":[],"mappings":"AAYA;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,OAAO,CAItC;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,OAAO,CAAC,CAGxD"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Is the phone offline? NetInfo answers, with one rule both helpers here share.
3
+ *
4
+ * `isConnected === false` is offline, and nothing else is. NetInfo reports `null` while it has not
5
+ * worked it out yet, which happens at every launch; reading that as offline puts an offline banner
6
+ * on screen for the first moment of every session.
7
+ */
8
+ import NetInfo, {} from "@react-native-community/netinfo";
9
+ import { useEffect, useState } from "react";
10
+ const offline = (state) => state.isConnected === false;
11
+ /**
12
+ * True while the phone has no connection.
13
+ *
14
+ * React Query already pauses on its own once `installQueryManagers` from `@gusnips/native/query`
15
+ * is running. This is for a screen that should SAY it is offline instead of showing old data as
16
+ * if it were current.
17
+ */
18
+ export function useIsOffline() {
19
+ const [isOffline, setIsOffline] = useState(false);
20
+ useEffect(() => NetInfo.addEventListener((state) => setIsOffline(offline(state))), []);
21
+ return isOffline;
22
+ }
23
+ /**
24
+ * Was that failure the phone, or the server?
25
+ *
26
+ * React Native's `fetch` rejects with the same bare `TypeError` when the server is down and when
27
+ * the phone has no signal. Ask this before telling somebody the service is having trouble: only a
28
+ * `false` here means the service is to blame. A NetInfo that cannot answer counts as online, so
29
+ * the doubt goes to the message that has a retry beside it.
30
+ */
31
+ export async function isDeviceOffline() {
32
+ const state = await NetInfo.fetch().catch(() => null);
33
+ return state !== null && offline(state);
34
+ }
35
+ //# sourceMappingURL=network.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"network.js","sourceRoot":"","sources":["../src/network.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,OAAO,EAAE,EAAqB,MAAM,iCAAiC,CAAC;AAC7E,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE5C,MAAM,OAAO,GAAG,CAAC,KAAmB,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,KAAK,KAAK,CAAC;AAErE;;;;;;GAMG;AACH,MAAM,UAAU,YAAY;IAC1B,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAClD,SAAS,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,YAAY,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvF,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe;IACnC,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IACtD,OAAO,KAAK,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC,CAAC;AAC1C,CAAC"}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=gusnips-native-css.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gusnips-native-css.d.ts","sourceRoot":"","sources":["../../../src/node/bin/gusnips-native-css.ts"],"names":[],"mappings":""}