jazz-react-native 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,289 @@
1
+ import React, { useMemo, useState, useEffect } from "react";
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ TextInput,
7
+ StyleSheet,
8
+ } from "react-native";
9
+ import { AgentSecret } from "cojson";
10
+ import { Account, ID } from "jazz-tools";
11
+ import { RNDemoAuth } from "./DemoAuthMethod.js";
12
+
13
+ type DemoAuthState = (
14
+ | {
15
+ state: "uninitialized";
16
+ }
17
+ | {
18
+ state: "loading";
19
+ }
20
+ | {
21
+ state: "ready";
22
+ existingUsers: string[];
23
+ signUp: (username: string) => void;
24
+ logInAs: (existingUser: string) => void;
25
+ }
26
+ | {
27
+ state: "signedIn";
28
+ logOut: () => void;
29
+ }
30
+ ) & {
31
+ errors: string[];
32
+ };
33
+
34
+ /** @category Auth Providers */
35
+ export function useDemoAuth({
36
+ seedAccounts,
37
+ }: {
38
+ seedAccounts?: {
39
+ [name: string]: { accountID: ID<Account>; accountSecret: AgentSecret };
40
+ };
41
+ } = {}) {
42
+ const [state, setState] = useState<DemoAuthState>({
43
+ state: "loading",
44
+ errors: [],
45
+ });
46
+
47
+ const [authMethod, setAuthMethod] = useState<RNDemoAuth | null>(null);
48
+
49
+ const authMethodPromise = useMemo(() => {
50
+ return RNDemoAuth.init(
51
+ {
52
+ onReady: async ({ signUp, getExistingUsers, logInAs }) => {
53
+ const existingUsers = await getExistingUsers();
54
+ setState({
55
+ state: "ready",
56
+ signUp,
57
+ existingUsers,
58
+ logInAs,
59
+ errors: [],
60
+ });
61
+ },
62
+ onSignedIn: ({ logOut }) => {
63
+ setState({ state: "signedIn", logOut, errors: [] });
64
+ },
65
+ onError: (error) => {
66
+ setState((current) => ({
67
+ ...current,
68
+ errors: [...current.errors, error.toString()],
69
+ }));
70
+ },
71
+ },
72
+ seedAccounts,
73
+ );
74
+ }, [seedAccounts]);
75
+
76
+ useEffect(() => {
77
+ async function init() {
78
+ const auth = await authMethodPromise;
79
+ setAuthMethod(auth);
80
+ }
81
+ if (authMethod) return;
82
+ void init();
83
+ }, [seedAccounts]);
84
+
85
+ return [authMethod, state] as const;
86
+ }
87
+
88
+ export const DemoAuthBasicUI = ({
89
+ appName,
90
+ state,
91
+ }: {
92
+ appName: string;
93
+ state: DemoAuthState;
94
+ }) => {
95
+ const darkMode = false;
96
+ const [username, setUsername] = useState<string>("");
97
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
98
+
99
+ const handleSignUp = () => {
100
+ if (state.state !== "ready") return;
101
+ if (username.trim() === "") {
102
+ setErrorMessage("Display name is required");
103
+ } else {
104
+ setErrorMessage(null);
105
+ state.signUp(username);
106
+ }
107
+ };
108
+
109
+ return (
110
+ <View
111
+ style={[
112
+ styles.container,
113
+ darkMode ? styles.darkBackground : styles.lightBackground,
114
+ ]}
115
+ >
116
+ {state.state === "loading" ? (
117
+ <Text style={styles.loadingText}>Loading...</Text>
118
+ ) : state.state === "ready" ? (
119
+ <View style={styles.formContainer}>
120
+ <Text
121
+ style={[
122
+ styles.headerText,
123
+ darkMode ? styles.darkText : styles.lightText,
124
+ ]}
125
+ >
126
+ {appName}
127
+ </Text>
128
+
129
+ {state.errors.map((error) => (
130
+ <Text key={error} style={styles.errorText}>
131
+ {error}
132
+ </Text>
133
+ ))}
134
+
135
+ {errorMessage && (
136
+ <Text style={styles.errorText}>{errorMessage}</Text>
137
+ )}
138
+
139
+ <TextInput
140
+ placeholder="Display name"
141
+ value={username}
142
+ onChangeText={setUsername}
143
+ placeholderTextColor={darkMode ? "#fff" : "#000"}
144
+ style={[
145
+ styles.textInput,
146
+ darkMode ? styles.darkInput : styles.lightInput,
147
+ ]}
148
+ />
149
+
150
+ <TouchableOpacity
151
+ onPress={handleSignUp}
152
+ style={[
153
+ styles.button,
154
+ darkMode ? styles.darkButton : styles.lightButton,
155
+ ]}
156
+ >
157
+ <Text
158
+ style={
159
+ darkMode
160
+ ? styles.darkButtonText
161
+ : styles.lightButtonText
162
+ }
163
+ >
164
+ Sign Up as new account
165
+ </Text>
166
+ </TouchableOpacity>
167
+
168
+ <View style={styles.existingUsersContainer}>
169
+ {state.existingUsers.map((user) => (
170
+ <TouchableOpacity
171
+ key={user}
172
+ onPress={() => state.logInAs(user)}
173
+ style={[
174
+ styles.existingUserButton,
175
+ darkMode
176
+ ? styles.darkUserButton
177
+ : styles.lightUserButton,
178
+ ]}
179
+ >
180
+ <Text
181
+ style={
182
+ darkMode
183
+ ? styles.darkText
184
+ : styles.lightText
185
+ }
186
+ >
187
+ Log In as "{user}"
188
+ </Text>
189
+ </TouchableOpacity>
190
+ ))}
191
+ </View>
192
+ </View>
193
+ ) : null}
194
+ </View>
195
+ );
196
+ };
197
+
198
+ const styles = StyleSheet.create({
199
+ container: {
200
+ flex: 1,
201
+ justifyContent: "center",
202
+ alignItems: "center",
203
+ padding: 20,
204
+ },
205
+ formContainer: {
206
+ width: "80%",
207
+ alignItems: "center",
208
+ justifyContent: "center",
209
+ },
210
+ headerText: {
211
+ fontSize: 24,
212
+ marginBottom: 20,
213
+ },
214
+ errorText: {
215
+ color: "red",
216
+ marginVertical: 5,
217
+ textAlign: "center",
218
+ },
219
+ textInput: {
220
+ borderWidth: 1,
221
+ padding: 10,
222
+ marginVertical: 10,
223
+ width: "100%",
224
+ borderRadius: 6,
225
+ },
226
+ darkInput: {
227
+ borderColor: "#444",
228
+ backgroundColor: "#000",
229
+ color: "#fff",
230
+ },
231
+ lightInput: {
232
+ borderColor: "#ddd",
233
+ backgroundColor: "#fff",
234
+ color: "#000",
235
+ },
236
+ button: {
237
+ paddingVertical: 15,
238
+ paddingHorizontal: 10,
239
+ borderRadius: 6,
240
+ width: "100%",
241
+ marginVertical: 10,
242
+ },
243
+ darkButton: {
244
+ backgroundColor: "#444",
245
+ },
246
+ lightButton: {
247
+ backgroundColor: "#ddd",
248
+ },
249
+ darkButtonText: {
250
+ color: "#fff",
251
+ textAlign: "center",
252
+ },
253
+ lightButtonText: {
254
+ color: "#000",
255
+ textAlign: "center",
256
+ },
257
+ existingUsersContainer: {
258
+ width: "100%",
259
+ marginTop: 20,
260
+ },
261
+ existingUserButton: {
262
+ paddingVertical: 15,
263
+ paddingHorizontal: 10,
264
+ borderRadius: 6,
265
+ marginVertical: 5,
266
+ },
267
+ darkUserButton: {
268
+ backgroundColor: "#222",
269
+ },
270
+ lightUserButton: {
271
+ backgroundColor: "#eee",
272
+ },
273
+ loadingText: {
274
+ fontSize: 18,
275
+ color: "#888",
276
+ },
277
+ darkText: {
278
+ color: "#fff",
279
+ },
280
+ lightText: {
281
+ color: "#000",
282
+ },
283
+ darkBackground: {
284
+ backgroundColor: "#000",
285
+ },
286
+ lightBackground: {
287
+ backgroundColor: "#fff",
288
+ },
289
+ });
@@ -0,0 +1 @@
1
+ export * from "./DemoAuthUI.js";
package/src/index.ts ADDED
@@ -0,0 +1,275 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /* eslint-disable @typescript-eslint/no-unused-vars */
3
+ import {
4
+ CoValue,
5
+ ID,
6
+ AgentID,
7
+ SessionID,
8
+ cojsonInternals,
9
+ InviteSecret,
10
+ Account,
11
+ CoValueClass,
12
+ CryptoProvider,
13
+ AuthMethod,
14
+ createJazzContext,
15
+ AnonymousJazzAgent,
16
+ } from "jazz-tools";
17
+
18
+ import { PureJSCrypto } from "jazz-tools/native";
19
+ import { RawAccountID } from "cojson";
20
+ import { createWebSocketPeer } from "cojson-transport-ws";
21
+ import { MMKV } from "react-native-mmkv";
22
+ import NetInfo from "@react-native-community/netinfo";
23
+ import * as Linking from "expo-linking";
24
+
25
+ export { RNDemoAuth } from "./auth/DemoAuthMethod.js";
26
+
27
+ import { NativeStorageContext } from "./native-storage.js";
28
+
29
+ /** @category Context Creation */
30
+ export type BrowserContext<Acc extends Account> = {
31
+ me: Acc;
32
+ logOut: () => void;
33
+ // TODO: Symbol.dispose?
34
+ done: () => void;
35
+ };
36
+
37
+ export type BrowserGuestContext = {
38
+ guest: AnonymousJazzAgent;
39
+ logOut: () => void;
40
+ done: () => void;
41
+ };
42
+
43
+ export type BrowserContextOptions<Acc extends Account> = {
44
+ auth: AuthMethod;
45
+ AccountSchema: CoValueClass<Acc> & {
46
+ fromNode: (typeof Account)["fromNode"];
47
+ };
48
+ } & BaseBrowserContextOptions;
49
+
50
+ export type BaseBrowserContextOptions = {
51
+ peer: `wss://${string}` | `ws://${string}`;
52
+ reconnectionTimeout?: number;
53
+ storage?: "indexedDB" | "singleTabOPFS";
54
+ crypto?: CryptoProvider;
55
+ };
56
+
57
+ /** @category Context Creation */
58
+ export async function createJazzRNContext<Acc extends Account>(
59
+ options: BrowserContextOptions<Acc>,
60
+ ): Promise<BrowserContext<Acc>>;
61
+ export async function createJazzRNContext(
62
+ options: BaseBrowserContextOptions,
63
+ ): Promise<BrowserGuestContext>;
64
+ export async function createJazzRNContext<Acc extends Account>(
65
+ options: BrowserContextOptions<Acc> | BaseBrowserContextOptions,
66
+ ): Promise<BrowserContext<Acc> | BrowserGuestContext>;
67
+ export async function createJazzRNContext<Acc extends Account>(
68
+ options: BrowserContextOptions<Acc> | BaseBrowserContextOptions,
69
+ ): Promise<BrowserContext<Acc> | BrowserGuestContext> {
70
+ const firstWsPeer = createWebSocketPeer({
71
+ websocket: new WebSocket(options.peer),
72
+ id: options.peer + "@" + new Date().toISOString(),
73
+ role: "server",
74
+ expectPings: true,
75
+ });
76
+ let shouldTryToReconnect = true;
77
+
78
+ let currentReconnectionTimeout = options.reconnectionTimeout || 500;
79
+
80
+ const unsubscribeNetworkChange = NetInfo.addEventListener((state) => {
81
+ if (state.isConnected) {
82
+ currentReconnectionTimeout = options.reconnectionTimeout || 500;
83
+ }
84
+ });
85
+
86
+ const context =
87
+ "auth" in options
88
+ ? await createJazzContext({
89
+ AccountSchema: options.AccountSchema,
90
+ auth: options.auth,
91
+ crypto: await PureJSCrypto.create(),
92
+ peersToLoadFrom: [firstWsPeer],
93
+ sessionProvider: provideLockSession,
94
+ })
95
+ : await createJazzContext({
96
+ crypto: await PureJSCrypto.create(),
97
+ peersToLoadFrom: [firstWsPeer],
98
+ });
99
+
100
+ const node =
101
+ "account" in context
102
+ ? context.account._raw.core.node
103
+ : context.agent.node;
104
+
105
+ async function websocketReconnectLoop() {
106
+ while (shouldTryToReconnect) {
107
+ if (
108
+ Object.keys(node.syncManager.peers).some((peerId) =>
109
+ peerId.includes(options.peer),
110
+ )
111
+ ) {
112
+ // TODO: this might drain battery, use listeners instead
113
+ await new Promise((resolve) => setTimeout(resolve, 100));
114
+ } else {
115
+ console.log(
116
+ "Websocket disconnected, trying to reconnect in " +
117
+ currentReconnectionTimeout +
118
+ "ms",
119
+ );
120
+ currentReconnectionTimeout = Math.min(
121
+ currentReconnectionTimeout * 2,
122
+ 30000,
123
+ );
124
+ await new Promise<void>((resolve) => {
125
+ setTimeout(resolve, currentReconnectionTimeout);
126
+ const _unsubscribeNetworkChange = NetInfo.addEventListener(
127
+ (state) => {
128
+ if (state.isConnected) {
129
+ resolve();
130
+ _unsubscribeNetworkChange();
131
+ }
132
+ },
133
+ );
134
+ });
135
+
136
+ node.syncManager.addPeer(
137
+ createWebSocketPeer({
138
+ websocket: new WebSocket(options.peer),
139
+ id: options.peer + "@" + new Date().toISOString(),
140
+ role: "server",
141
+ }),
142
+ );
143
+ }
144
+ }
145
+ }
146
+
147
+ void websocketReconnectLoop();
148
+
149
+ return "account" in context
150
+ ? {
151
+ me: context.account,
152
+ done: () => {
153
+ shouldTryToReconnect = false;
154
+ unsubscribeNetworkChange?.();
155
+ context.done();
156
+ },
157
+ logOut: () => {
158
+ context.logOut();
159
+ },
160
+ }
161
+ : {
162
+ guest: context.agent,
163
+ done: () => {
164
+ shouldTryToReconnect = false;
165
+ unsubscribeNetworkChange?.();
166
+ context.done();
167
+ },
168
+ logOut: () => {
169
+ context.logOut();
170
+ },
171
+ };
172
+ }
173
+
174
+ /** @category Auth Providers */
175
+ export type SessionProvider = (
176
+ accountID: ID<Account> | AgentID,
177
+ ) => Promise<SessionID>;
178
+
179
+ export async function provideLockSession(
180
+ accountID: ID<Account> | AgentID,
181
+ crypto: CryptoProvider,
182
+ ) {
183
+ const sessionDone = () => {};
184
+
185
+ const storage = NativeStorageContext.getInstance().getStorage();
186
+
187
+ const sessionID =
188
+ ((await storage.get(accountID)) as SessionID) ||
189
+ crypto.newRandomSessionID(accountID as RawAccountID | AgentID);
190
+ await storage.set(accountID, sessionID);
191
+
192
+ return Promise.resolve({
193
+ sessionID,
194
+ sessionDone,
195
+ });
196
+ }
197
+
198
+ const window = {
199
+ location: {
200
+ href: "#",
201
+ },
202
+ history: {
203
+ replaceState: (a: any, b: any, c: any) => {},
204
+ },
205
+ };
206
+
207
+ /** @category Invite Links */
208
+ export function createInviteLink<C extends CoValue>(
209
+ value: C,
210
+ role: "reader" | "writer" | "admin",
211
+ { baseURL, valueHint }: { baseURL?: string; valueHint?: string } = {},
212
+ ): string {
213
+ const coValueCore = value._raw.core;
214
+ let currentCoValue = coValueCore;
215
+
216
+ while (currentCoValue.header.ruleset.type === "ownedByGroup") {
217
+ currentCoValue = currentCoValue.getGroup().core;
218
+ }
219
+
220
+ if (currentCoValue.header.ruleset.type !== "group") {
221
+ throw new Error("Can't create invite link for object without group");
222
+ }
223
+
224
+ const group = cojsonInternals.expectGroup(
225
+ currentCoValue.getCurrentContent(),
226
+ );
227
+ const inviteSecret = group.createInvite(role);
228
+
229
+ return `${baseURL}/invite/${valueHint ? valueHint + "/" : ""}${
230
+ value.id
231
+ }/${inviteSecret}`;
232
+ }
233
+
234
+ /** @category Invite Links */
235
+ export function parseInviteLink<C extends CoValue>(
236
+ inviteURL: string,
237
+ ):
238
+ | {
239
+ valueID: ID<C>;
240
+ valueHint?: string;
241
+ inviteSecret: InviteSecret;
242
+ }
243
+ | undefined {
244
+ const url = Linking.parse(inviteURL);
245
+ const parts = url.path?.split("/");
246
+
247
+ if (!parts || parts[0] !== "invite") {
248
+ return undefined;
249
+ }
250
+
251
+ let valueHint: string | undefined;
252
+ let valueID: ID<C> | undefined;
253
+ let inviteSecret: InviteSecret | undefined;
254
+
255
+ if (parts.length === 4) {
256
+ valueHint = parts[1];
257
+ valueID = parts[2] as ID<C>;
258
+ inviteSecret = parts[3] as InviteSecret;
259
+ } else if (parts.length === 3) {
260
+ valueID = parts[1] as ID<C>;
261
+ inviteSecret = parts[2] as InviteSecret;
262
+ }
263
+
264
+ if (!valueID || !inviteSecret) {
265
+ return undefined;
266
+ }
267
+
268
+ return { valueID, inviteSecret, valueHint };
269
+ }
270
+
271
+ /////////
272
+
273
+ export * from "./provider.js";
274
+ export * from "./auth/auth.js";
275
+ export * from "./native-storage.js";
package/src/media.tsx ADDED
@@ -0,0 +1,68 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { ImageDefinition } from "jazz-tools";
3
+
4
+ /** @category Media */
5
+ export function useProgressiveImg({
6
+ image,
7
+ maxWidth,
8
+ }: {
9
+ image: ImageDefinition | null | undefined;
10
+ maxWidth?: number;
11
+ }) {
12
+ const [current, setCurrent] = useState<
13
+ | { src?: string; res?: `${number}x${number}` | "placeholder" }
14
+ | undefined
15
+ >(undefined);
16
+
17
+ useEffect(() => {
18
+ let lastHighestRes: string | undefined;
19
+ if (!image) return;
20
+ const unsub = image.subscribe({}, (update) => {
21
+ const highestRes = update?.highestResAvailable({ maxWidth });
22
+ if (highestRes) {
23
+ if (highestRes.res !== lastHighestRes) {
24
+ lastHighestRes = highestRes.res;
25
+ const blob = highestRes.stream.toBlob();
26
+ if (blob) {
27
+ const blobURI = URL.createObjectURL(blob);
28
+ setCurrent({ src: blobURI, res: highestRes.res });
29
+ return () => {
30
+ setTimeout(() => URL.revokeObjectURL(blobURI), 200);
31
+ };
32
+ }
33
+ }
34
+ } else {
35
+ setCurrent({
36
+ src: update?.placeholderDataURL,
37
+ res: "placeholder",
38
+ });
39
+ }
40
+ });
41
+
42
+ return unsub;
43
+ }, [image?.id, maxWidth]);
44
+
45
+ return {
46
+ src: current?.src,
47
+ res: current?.res,
48
+ originalSize: image?.originalSize,
49
+ };
50
+ }
51
+
52
+ /** @category Media */
53
+ export function ProgressiveImg({
54
+ children,
55
+ image,
56
+ maxWidth,
57
+ }: {
58
+ children: (result: {
59
+ src: string | undefined;
60
+ res: `${number}x${number}` | "placeholder" | undefined;
61
+ originalSize: readonly [number, number] | undefined;
62
+ }) => React.ReactNode;
63
+ image: ImageDefinition | null | undefined;
64
+ maxWidth?: number;
65
+ }) {
66
+ const result = useProgressiveImg({ image, maxWidth });
67
+ return result && children(result);
68
+ }
@@ -0,0 +1,35 @@
1
+ export interface NativeStorage {
2
+ get(key: string): Promise<string | undefined>;
3
+ set(key: string, value: string): Promise<void>;
4
+ delete(key: string): Promise<void>;
5
+ clearAll(): Promise<void>;
6
+ }
7
+
8
+ export class NativeStorageContext {
9
+ private static instance: NativeStorageContext;
10
+ private storageInstance: NativeStorage | null = null;
11
+
12
+ private constructor() {}
13
+
14
+ public static getInstance(): NativeStorageContext {
15
+ if (!NativeStorageContext.instance) {
16
+ NativeStorageContext.instance = new NativeStorageContext();
17
+ }
18
+ return NativeStorageContext.instance;
19
+ }
20
+
21
+ public initialize(db: NativeStorage): void {
22
+ if (!this.storageInstance) {
23
+ this.storageInstance = db;
24
+ }
25
+ }
26
+
27
+ public getStorage(): NativeStorage {
28
+ if (!this.storageInstance) {
29
+ throw new Error("Storage instance is not initialized.");
30
+ }
31
+ return this.storageInstance;
32
+ }
33
+ }
34
+
35
+ export default NativeStorageContext;