a tool for shared writing and social publishing
1"use client"; 2import { 3 createContext, 4 useCallback, 5 useContext, 6 useEffect, 7 useMemo, 8 useRef, 9 useState, 10} from "react"; 11import { useSubscribe } from "src/replicache/useSubscribe"; 12import { 13 DeepReadonlyObject, 14 PushRequest, 15 PushRequestV1, 16 Replicache, 17 WriteTransaction, 18} from "replicache"; 19import { mutations } from "./mutations"; 20import { Attributes } from "./attributes"; 21import { Attribute, Data, FilterAttributes } from "./attributes"; 22import { clientMutationContext } from "./clientMutationContext"; 23import { supabaseBrowserClient } from "supabase/browserClient"; 24import { callRPC } from "app/api/rpc/client"; 25import { UndoManager } from "@rocicorp/undo"; 26import { addShortcut } from "src/shortcuts"; 27import { createUndoManager } from "src/undoManager"; 28import { RealtimeChannel } from "@supabase/supabase-js"; 29 30export type Fact<A extends Attribute> = { 31 id: string; 32 entity: string; 33 attribute: A; 34 data: Data<A>; 35}; 36 37let ReplicacheContext = createContext({ 38 undoManager: createUndoManager(), 39 rootEntity: "" as string, 40 rep: null as null | Replicache<ReplicacheMutators>, 41 initialFacts: [] as Fact<Attribute>[], 42 permission_token: {} as PermissionToken, 43}); 44export function useReplicache() { 45 return useContext(ReplicacheContext); 46} 47export type ReplicacheMutators = { 48 [k in keyof typeof mutations]: ( 49 tx: WriteTransaction, 50 args: Parameters<(typeof mutations)[k]>[0], 51 ) => Promise<void>; 52}; 53 54export type PermissionToken = { 55 id: string; 56 root_entity: string; 57 permission_token_rights: { 58 created_at: string; 59 entity_set: string; 60 change_entity_set: boolean; 61 create_token: boolean; 62 token: string; 63 read: boolean; 64 write: boolean; 65 }[]; 66}; 67export function ReplicacheProvider(props: { 68 rootEntity: string; 69 initialFacts: Fact<Attribute>[]; 70 token: PermissionToken; 71 name: string; 72 children: React.ReactNode; 73 initialFactsOnly?: boolean; 74 disablePull?: boolean; 75}) { 76 let [rep, setRep] = useState<null | Replicache<ReplicacheMutators>>(null); 77 let [undoManager] = useState(createUndoManager()); 78 useEffect(() => { 79 return addShortcut([ 80 { 81 metaKey: true, 82 key: "z", 83 handler: () => { 84 undoManager.undo(); 85 }, 86 }, 87 { 88 metaKey: true, 89 shift: true, 90 key: "z", 91 handler: () => { 92 undoManager.redo(); 93 }, 94 }, 95 { 96 metaKey: true, 97 shift: true, 98 key: "Z", 99 handler: () => { 100 undoManager.redo(); 101 }, 102 }, 103 ]); 104 }, [undoManager]); 105 useEffect(() => { 106 if (props.initialFactsOnly) return; 107 let supabase = supabaseBrowserClient(); 108 let newRep = new Replicache({ 109 pullInterval: props.disablePull ? null : undefined, 110 pushDelay: 500, 111 mutators: Object.fromEntries( 112 Object.keys(mutations).map((m) => { 113 return [ 114 m, 115 async (tx: WriteTransaction, args: any) => { 116 await mutations[m as keyof typeof mutations]( 117 args, 118 clientMutationContext(tx, { 119 permission_token_id: props.token.id, 120 undoManager, 121 rep: newRep, 122 ignoreUndo: args.ignoreUndo || tx.reason !== "initial", 123 defaultEntitySet: 124 props.token.permission_token_rights[0]?.entity_set, 125 }), 126 ); 127 }, 128 ]; 129 }), 130 ) as ReplicacheMutators, 131 licenseKey: "l381074b8d5224dabaef869802421225a", 132 pusher: async (pushRequest) => { 133 const batchSize = 250; 134 let smolpushRequest = { 135 ...pushRequest, 136 mutations: pushRequest.mutations.slice(0, batchSize), 137 } as PushRequest; 138 let response = ( 139 await callRPC("push", { 140 pushRequest: smolpushRequest, 141 token: props.token, 142 rootEntity: props.name, 143 }) 144 ).result; 145 if (pushRequest.mutations.length > batchSize) 146 setTimeout(() => { 147 newRep.push(); 148 }, 50); 149 return { 150 response, 151 httpRequestInfo: { errorMessage: "", httpStatusCode: 200 }, 152 }; 153 }, 154 puller: async (pullRequest) => { 155 let res = await callRPC("pull", { 156 pullRequest, 157 token_id: props.token.id, 158 }); 159 return { 160 response: res, 161 httpRequestInfo: { errorMessage: "", httpStatusCode: 200 }, 162 }; 163 }, 164 name: props.name, 165 indexes: { 166 eav: { jsonPointer: "/indexes/eav", allowEmpty: true }, 167 vae: { jsonPointer: "/indexes/vae", allowEmpty: true }, 168 }, 169 }); 170 171 setRep(newRep); 172 let channel: RealtimeChannel | null = null; 173 if (!props.disablePull) { 174 channel = supabase.channel(`rootEntity:${props.name}`); 175 176 channel.on("broadcast", { event: "poke" }, () => { 177 newRep.pull(); 178 }); 179 channel.subscribe(); 180 } 181 return () => { 182 newRep.close(); 183 setRep(null); 184 channel?.unsubscribe(); 185 }; 186 }, [props.name, props.initialFactsOnly, props.token, props.disablePull]); 187 return ( 188 <ReplicacheContext.Provider 189 value={{ 190 undoManager, 191 rep, 192 rootEntity: props.rootEntity, 193 initialFacts: props.initialFacts, 194 permission_token: props.token, 195 }} 196 > 197 {props.children} 198 </ReplicacheContext.Provider> 199 ); 200} 201 202type CardinalityResult<A extends Attribute> = 203 (typeof Attributes)[A]["cardinality"] extends "one" 204 ? DeepReadonlyObject<Fact<A>> | null 205 : DeepReadonlyObject<Fact<A>>[]; 206export function useEntity<A extends Attribute>( 207 entity: string | null, 208 attribute: A, 209): CardinalityResult<A> { 210 let { rep, initialFacts } = useReplicache(); 211 let fallbackData = useMemo( 212 () => 213 initialFacts.filter( 214 (f) => f.entity === entity && f.attribute === attribute, 215 ), 216 [entity, attribute, initialFacts], 217 ); 218 let data = useSubscribe( 219 rep, 220 async (tx) => { 221 if (entity === null) return null; 222 let initialized = await tx.get("initialized"); 223 if (!initialized) return null; 224 return ( 225 ( 226 await tx 227 .scan<Fact<A>>({ 228 indexName: "eav", 229 prefix: `${entity}-${attribute}`, 230 }) 231 // hack to handle rich bluesky-post type 232 .toArray() 233 ).filter((f) => f.attribute === attribute) 234 ); 235 }, 236 { 237 default: null, 238 dependencies: [entity, attribute], 239 }, 240 ); 241 let d = data || fallbackData; 242 let a = Attributes[attribute]; 243 return a.cardinality === "many" 244 ? ((a.type === "ordered-reference" 245 ? d.sort((a, b) => { 246 return ( 247 a as Fact<keyof FilterAttributes<{ type: "ordered-reference" }>> 248 ).data.position > 249 (b as Fact<keyof FilterAttributes<{ type: "ordered-reference" }>>) 250 .data.position 251 ? 1 252 : -1; 253 }) 254 : d) as CardinalityResult<A>) 255 : d.length === 0 && data === null 256 ? (null as CardinalityResult<A>) 257 : (d[0] as CardinalityResult<A>); 258} 259 260export function useReferenceToEntity< 261 A extends keyof FilterAttributes<{ type: "reference" | "ordered-reference" }>, 262>(attribute: A, entity: string) { 263 let { rep, initialFacts } = useReplicache(); 264 let fallbackData = useMemo( 265 () => 266 initialFacts.filter( 267 (f) => 268 (f as Fact<A>).data.value === entity && f.attribute === attribute, 269 ), 270 [entity, attribute, initialFacts], 271 ); 272 let data = useSubscribe( 273 rep, 274 async (tx) => { 275 if (entity === null) return null; 276 let initialized = await tx.get("initialized"); 277 if (!initialized) return null; 278 return ( 279 await tx 280 .scan<Fact<A>>({ indexName: "vae", prefix: `${entity}-${attribute}` }) 281 .toArray() 282 ).filter((f) => f.attribute === attribute); 283 }, 284 { 285 default: null, 286 dependencies: [entity, attribute], 287 }, 288 ); 289 return data || (fallbackData as Fact<A>[]); 290}