jazz-react-native 0.8.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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;