@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +310 -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 +44 -0
  24. package/dist/node/css.d.ts.map +1 -0
  25. package/dist/node/css.js +181 -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.ts +246 -0
  55. package/src/node/metro.test.ts +66 -0
  56. package/src/node/metro.ts +143 -0
  57. package/src/query.ts +41 -0
  58. package/src/sse.test.ts +195 -0
  59. package/src/sse.ts +174 -0
  60. package/src/supabase.ts +29 -0
  61. package/src/theme.test.ts +92 -0
  62. 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,311 @@
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 1.30.1, or use a version after 1.30.2.** 1.30.2 breaks react-native-css's
125
+ compiler, and react-native-css's own check for it is swallowed by an empty `catch`. The build
126
+ refuses 1.30.2 instead. Pin it with `"overrides": { "lightningcss": "1.30.1" }` in the root
127
+ `package.json`: a devDependency is not enough, because react-native-css loads the copy under
128
+ `@expo/metro-config` first.
129
+ - **A `className` change hot-reloads.** Only a change to `global.css` or your tokens needs the
130
+ build to run again.
131
+ - **There is no global font.** react-native-css drops element and `*` selectors, so a text without
132
+ a `font-*` class renders in the system font.
133
+
134
+ ### Colours for code
135
+
136
+ An icon's `color`, the navigation theme and the status bar take a hex string, not a class. Name
137
+ the colours in a JSON file, each as a class per scheme:
138
+
139
+ ```json
140
+ {
141
+ "text": { "light": "text-ink", "dark": "dark:text-ink-dark" },
142
+ "canvas": { "light": "bg-surface", "dark": "dark:bg-surface-dark" }
143
+ }
144
+ ```
145
+
146
+ `gusnips-native-css --palette palette.json` writes `colors.ts` beside the stylesheet, with the hex
147
+ each class compiles to. The colours come from the stylesheet itself, so a change of tokens moves the
148
+ icons with the text. Tailwind writes only classes it finds in your files, so keep each palette
149
+ class in your source; the build stops and names any it cannot find.
150
+
151
+ From a script, the same build is `buildNativeCss({ palette })` from `@gusnips/native/css`.
152
+
153
+ ## Streams
154
+
155
+ React Native's `fetch` cannot read a response as it arrives, so `readSseStream` from
156
+ `@gusnips/react` has nothing to read. `openSseStream` uses `XMLHttpRequest`, which can, and the
157
+ same frame parser:
158
+
159
+ ```ts
160
+ import { openSseStream } from "@gusnips/native";
161
+
162
+ const stream = openSseStream({
163
+ url: `${API_URL}/chat/stream`,
164
+ body: { message },
165
+ session, // the SessionAdapter your createApiClient uses
166
+ onFrame: ({ event, data }) => handle(event, JSON.parse(data)),
167
+ onError: showError,
168
+ });
169
+
170
+ stream.abort(); // when the screen closes
171
+ ```
172
+
173
+ - A refused stream fails with the same `ApiError` your API client throws, with the code, the
174
+ request id and the `Retry-After` wait. So a 402 on a stream gets the same sentence and button as
175
+ any other 402. A lost connection is a plain `Error`, which the describer reads as "never
176
+ landed".
177
+ - It sends the session's token, and on a 401 it calls the session's `refresh()` once and opens
178
+ again. Pass the same adapter your API client uses: its refresh runs once for everyone, so a
179
+ stream and a page load do not refresh at the same time (the auth server rotates the refresh
180
+ token, and the loser of that race signs the person out). No token back, and the stream fails
181
+ with the 401; ending the session stays the API client's job. `headers` adds anything else, like
182
+ the client's own option.
183
+ - `data` is raw text. Parse it the way your server writes it.
184
+ - No reconnect. Reopen the stream from where your screen knows how to recover.
185
+ - The whole response stays in memory until the stream ends. Fine for a chat answer; not for a feed
186
+ left open all day.
187
+
188
+ ## React Query
189
+
190
+ ```ts
191
+ import { queryDefaults } from "@gusnips/react";
192
+ import { installQueryManagers } from "@gusnips/native/query";
193
+
194
+ installQueryManagers();
195
+ export const queryClient = new QueryClient({ defaultOptions: queryDefaults() });
196
+ ```
197
+
198
+ React Query's own defaults watch the browser's window and `navigator.onLine`, which a phone does
199
+ not have. After this, retries wait while the app is in the background or offline, and stale
200
+ queries run again when the connection returns.
201
+
202
+ It does **not** refetch when the app comes back to the foreground: `queryDefaults` leaves
203
+ `refetchOnWindowFocus` off. For an app people leave open for days, you probably want it on:
204
+ `queryDefaults({ refetchOnWindowFocus: true })`.
205
+
206
+ ## Offline
207
+
208
+ ```ts
209
+ import { useIsOffline } from "@gusnips/native/network";
210
+
211
+ const offline = useIsOffline(); // true while the phone has no connection
212
+ ```
213
+
214
+ `isDeviceOffline()` answers the same question once. React Native's `fetch` fails the same way
215
+ whether the server is down or the phone has no signal, so ask it before you tell someone the
216
+ service is having trouble. While NetInfo is still working it out, both say "online", so no
217
+ offline banner flashes at launch.
218
+
219
+ ## Theme
220
+
221
+ The same options and the same `useTheme` as `@gusnips/react/theme`, applied with
222
+ `Appearance.setColorScheme`, which is what `useColorScheme()` and the `dark:` classes read:
223
+
224
+ ```ts
225
+ import { createNativeTheme } from "@gusnips/native/theme";
226
+
227
+ export const theme = createNativeTheme({ key: "app:theme" });
228
+
229
+ // In the root component:
230
+ useEffect(() => theme.start(), []);
231
+
232
+ // Anywhere:
233
+ const { mode, resolved, setMode } = theme.useTheme();
234
+ ```
235
+
236
+ `start()` loads the saved choice and puts it back each time the app returns to the foreground,
237
+ because Android drops the choice when it recreates the screen. A choice made while the saved one is
238
+ still loading wins over it.
239
+
240
+ ## Language
241
+
242
+ ```ts
243
+ import i18n from "i18next";
244
+ import { initReactI18next } from "react-i18next";
245
+ import { createNativeLanguage } from "@gusnips/native/i18n";
246
+
247
+ export const language = createNativeLanguage(i18n, {
248
+ supported: ["pt-BR", "en"],
249
+ storageKey: "app:language",
250
+ });
251
+
252
+ i18n.use(language.detector).use(initReactI18next).init({ resources, fallbackLng: "pt-BR" });
253
+ ```
254
+
255
+ At launch, call `language.restore()`. From your language picker, call `language.choose("en")`, or
256
+ `language.choose(null)` to follow the phone again.
257
+
258
+ - The first render is already in the phone's language. The saved choice is read after, and wins.
259
+ - The phone's languages are read in its order: on `pt-PT, en-US` the app picks `pt-BR`, and on
260
+ `fr-FR, en-US` it picks `en` instead of the fallback.
261
+ - Only `choose` saves a language. i18next hands every language change to the detector, including
262
+ the one it detects at launch, so a detector that saved there would treat the phone's language as
263
+ the person's choice, and the app would ignore the phone from then on.
264
+
265
+ ## Supabase
266
+
267
+ ```ts
268
+ import { supabaseAuthStorage } from "@gusnips/native/supabase";
269
+
270
+ export const authStorage = supabaseAuthStorage(SUPABASE_URL);
271
+ export const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
272
+ auth: { ...authStorage, detectSessionInUrl: false, flowType: "pkce" },
273
+ });
274
+
275
+ await signOutEvenOffline(supabase.auth, authStorage); // from @gusnips/react/supabase
276
+ ```
277
+
278
+ The session goes in AsyncStorage, not the keychain: a session is two tokens and the user's data in
279
+ one value, and it often passes the about 2 KB a keystore holds per value. The key is the one
280
+ supabase-js picks itself, so moving onto this signs nobody out. Pass the same object to
281
+ `signOutEvenOffline`, so the sign-out clears the key the client reads.
282
+
283
+ Use `flowType: "pkce"`. A sign-in returns through your app's URL scheme, which any app can claim,
284
+ and under the default flow that URL carries the refresh token.
285
+
286
+ ## Also worth knowing
287
+
288
+ - **Do not use `react-native-css/components/react-native-safe-area-context`.** In 3.0.7 everything
289
+ under it loses its `className` styles. Use `SafeAreaView` from `react-native-safe-area-context`
290
+ with `style={{ flex: 1 }}`, and put the colours on a styled `View` inside it.
291
+ - **`nativewind/babel` is a preset, not a plugin.** Put it in `presets` in `babel.config.js`.
292
+ - **Keep React at one version in the whole repo**, with the root `workspaces.catalog`. The Metro
293
+ resolver protects the bundle from bun's store layout; the catalog stops the versions drifting in
294
+ the first place.
295
+
296
+ ## Why the dependencies are peers
297
+
298
+ There are no `dependencies`. Each package here is one your app already has, or one that carries
299
+ native code, and an app must have exactly one copy of each:
300
+
301
+ - `react`, `react-native`: two copies of either breaks the app.
302
+ - `@gusnips/react` is a peer, not a dependency, on purpose. `@gusnips/vite` depends on it, so its
303
+ release pins one exact version, and an app on another version gets a second copy nested inside
304
+ vite. A peer uses yours. Its range is `>=0.10.0 <1`, so a new minor of `@gusnips/react` does not
305
+ need a release of this package.
306
+ - `@gusnips/http`: `openSseStream` reads the error envelope and `Retry-After` with it, the same
307
+ functions the API client uses. Same reasoning, same kind of range.
308
+ - AsyncStorage, NetInfo and react-native-localize hold native code that autolinking compiles into
309
+ the app, so each is optional and behind its own import.
310
+ - `tailwindcss`, `@tailwindcss/cli` and `react-native-css` are read from your app's folder at build
311
+ 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":""}