a tool for shared writing and social publishing

write publication_data through replicache

This lets us handle mutations gracefully! A little unscalable, but
honestly works well for what we're doing right now, and a useful pattern
to have in the back pocket

+178 -45
+19
app/api/rpc/[command]/pull.ts
··· 70 updated_at: string | null; 71 version: number; 72 }[]; 73 74 let clientGroup = ( 75 (data.client_groups as { ··· 98 value: FactWithIndexes(f as unknown as Fact<Attribute>), 99 } as const; 100 }), 101 ], 102 } as PullResponseV1; 103 },
··· 70 updated_at: string | null; 71 version: number; 72 }[]; 73 + let publication_data = data.publications as { 74 + description: string; 75 + title: string; 76 + }[]; 77 + let pub_patch = publication_data[0] 78 + ? [ 79 + { 80 + op: "put", 81 + key: "publication_description", 82 + value: publication_data[0].description, 83 + }, 84 + { 85 + op: "put", 86 + key: "publication_title", 87 + value: publication_data[0].title, 88 + }, 89 + ] 90 + : []; 91 92 let clientGroup = ( 93 (data.client_groups as { ··· 116 value: FactWithIndexes(f as unknown as Fact<Attribute>), 117 } as const; 118 }), 119 + ...pub_patch, 120 ], 121 } as PullResponseV1; 122 },
+1 -2
app/api/rpc/[command]/push.ts
··· 1 - import { PushResponse } from "replicache"; 2 import { serverMutationContext } from "src/replicache/serverMutationContext"; 3 import { mutations } from "src/replicache/mutations"; 4 import { eq } from "drizzle-orm"; ··· 79 try { 80 await mutations[name]( 81 mutation.args as any, 82 - serverMutationContext(tx, token_rights), 83 ); 84 } catch (e) { 85 console.log(
··· 1 import { serverMutationContext } from "src/replicache/serverMutationContext"; 2 import { mutations } from "src/replicache/mutations"; 3 import { eq } from "drizzle-orm"; ··· 78 try { 79 await mutations[name]( 80 mutation.args as any, 81 + serverMutationContext(tx, token.id, token_rights), 82 ); 83 } catch (e) { 84 console.log(
+30 -1
components/Input.tsx
··· 1 "use client"; 2 - import { useCallback, useEffect, useRef, type JSX } from "react"; 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 import { isIOS } from "src/utils/isDevice"; 5 ··· 25 autoFocus={isIOS() ? false : props.autoFocus} 26 ref={ref} 27 onMouseDown={onMouseDown} 28 /> 29 ); 30 };
··· 1 "use client"; 2 + import { useEffect, useRef, useState, type JSX } from "react"; 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 import { isIOS } from "src/utils/isDevice"; 5 ··· 25 autoFocus={isIOS() ? false : props.autoFocus} 26 ref={ref} 27 onMouseDown={onMouseDown} 28 + /> 29 + ); 30 + }; 31 + 32 + export const AsyncValueInput = ( 33 + props: { 34 + textarea?: boolean; 35 + } & JSX.IntrinsicElements["input"] & 36 + JSX.IntrinsicElements["textarea"], 37 + ) => { 38 + let [intermediateState, setIntermediateState] = useState( 39 + props.value as string, 40 + ); 41 + 42 + useEffect(() => { 43 + setIntermediateState(props.value as string); 44 + }, [props.value]); 45 + 46 + return ( 47 + <Input 48 + {...props} 49 + value={intermediateState} 50 + onChange={async (e) => { 51 + if (!props.onChange) return; 52 + setIntermediateState(e.currentTarget.value); 53 + await Promise.all([ 54 + props.onChange(e as React.ChangeEvent<HTMLInputElement>), 55 + ]); 56 + }} 57 /> 58 ); 59 };
+30 -38
components/Pages/PublicationMetadata.tsx
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { Input } from "components/Input"; 4 import { useEffect, useState } from "react"; 5 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 import { updateLeafletDraftMetadata } from "actions/publications/updateLeafletDraftMetadata"; 7 import { useReplicache } from "src/replicache"; 8 - import { useIdentityData } from "components/IdentityProvider"; 9 - import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 10 import { Separator } from "components/Layout"; 11 import { AtUri } from "@atproto/syntax"; 12 import { PubLeafletDocument } from "lexicons/api"; 13 - import { publications } from "drizzle/schema"; 14 import { 15 getBasePublicationURL, 16 getPublicationURL, 17 } from "app/lish/createPub/getPublicationURL"; 18 export const PublicationMetadata = ({ 19 cardBorderHidden, 20 }: { 21 cardBorderHidden: boolean; 22 }) => { 23 - let { permission_token } = useReplicache(); 24 - let { data: pub, mutate } = useLeafletPublicationData(); 25 - let [titleState, setTitleState] = useState(pub?.title || ""); 26 - let [descriptionState, setDescriptionState] = useState( 27 - pub?.description || "", 28 - ); 29 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 30 let publishedAt = record?.publishedAt; 31 32 - useEffect(() => { 33 - setTitleState(pub?.title || ""); 34 - setDescriptionState(pub?.description || ""); 35 - }, [pub]); 36 - useDebouncedEffect( 37 - async () => { 38 - if (!pub || !pub.publications) return; 39 - if (pub.title === titleState && pub.description === descriptionState) 40 - return; 41 - await updateLeafletDraftMetadata( 42 - permission_token.id, 43 - pub.publications?.uri, 44 - titleState, 45 - descriptionState, 46 - ); 47 - mutate(); 48 - }, 49 - 1000, 50 - [pub, titleState, descriptionState, permission_token], 51 - ); 52 if (!pub || !pub.publications) return null; 53 54 return ( ··· 66 Editor 67 </div> 68 </div> 69 - <Input 70 className="text-xl font-bold outline-none bg-transparent" 71 - value={titleState} 72 - onChange={(e) => { 73 - setTitleState(e.currentTarget.value); 74 }} 75 placeholder="Untitled" 76 /> 77 - <AutosizeTextarea 78 placeholder="add an optional description..." 79 className="italic text-secondary outline-none bg-transparent" 80 - value={descriptionState} 81 - onChange={(e) => { 82 - setDescriptionState(e.currentTarget.value); 83 }} 84 /> 85 {pub.doc ? (
··· 1 import Link from "next/link"; 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 + import { AsyncValueInput, Input } from "components/Input"; 4 import { useEffect, useState } from "react"; 5 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 import { updateLeafletDraftMetadata } from "actions/publications/updateLeafletDraftMetadata"; 7 import { useReplicache } from "src/replicache"; 8 + import { 9 + AsyncValueAutosizeTextarea, 10 + AutosizeTextarea, 11 + } from "components/utils/AutosizeTextarea"; 12 import { Separator } from "components/Layout"; 13 import { AtUri } from "@atproto/syntax"; 14 import { PubLeafletDocument } from "lexicons/api"; 15 import { 16 getBasePublicationURL, 17 getPublicationURL, 18 } from "app/lish/createPub/getPublicationURL"; 19 + import { useSubscribe } from "src/replicache/useSubscribe"; 20 export const PublicationMetadata = ({ 21 cardBorderHidden, 22 }: { 23 cardBorderHidden: boolean; 24 }) => { 25 + let { rep } = useReplicache(); 26 + let { data: pub } = useLeafletPublicationData(); 27 + let title = 28 + useSubscribe(rep, (tx) => tx.get<string>("publication_title")) || 29 + pub?.title || 30 + ""; 31 + let description = 32 + useSubscribe(rep, (tx) => tx.get<string>("publication_description")) || 33 + pub?.description || 34 + ""; 35 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 36 let publishedAt = record?.publishedAt; 37 38 if (!pub || !pub.publications) return null; 39 40 return ( ··· 52 Editor 53 </div> 54 </div> 55 + <AsyncValueInput 56 className="text-xl font-bold outline-none bg-transparent" 57 + value={title} 58 + onChange={async (e) => { 59 + await rep?.mutate.updatePublicationDraft({ 60 + title: e.currentTarget.value, 61 + description, 62 + }); 63 }} 64 placeholder="Untitled" 65 /> 66 + <AsyncValueAutosizeTextarea 67 placeholder="add an optional description..." 68 className="italic text-secondary outline-none bg-transparent" 69 + value={description} 70 + onChange={async (e) => { 71 + await rep?.mutate.updatePublicationDraft({ 72 + description: e.currentTarget.value, 73 + title, 74 + }); 75 }} 76 /> 77 {pub.doc ? (
+34 -1
components/utils/AutosizeTextarea.tsx
··· 1 - import { forwardRef, useImperativeHandle, useRef } from "react"; 2 import styles from "./textarea-styles.module.css"; 3 4 type Props = React.DetailedHTMLProps< ··· 21 ); 22 }, 23 ); 24 AutosizeTextarea.displayName = "Textarea";
··· 1 + import { 2 + forwardRef, 3 + useEffect, 4 + useImperativeHandle, 5 + useRef, 6 + useState, 7 + } from "react"; 8 import styles from "./textarea-styles.module.css"; 9 10 type Props = React.DetailedHTMLProps< ··· 27 ); 28 }, 29 ); 30 + 31 + export const AsyncValueAutosizeTextarea = forwardRef< 32 + HTMLTextAreaElement, 33 + Props 34 + >((props: Props, ref) => { 35 + let [intermediateState, setIntermediateState] = useState( 36 + props.value as string, 37 + ); 38 + 39 + useEffect(() => { 40 + setIntermediateState(props.value as string); 41 + }, [props.value]); 42 + 43 + return ( 44 + <AutosizeTextarea 45 + {...props} 46 + ref={ref} 47 + value={intermediateState} 48 + onChange={async (e) => { 49 + if (!props.onChange) return; 50 + setIntermediateState(e.currentTarget.value); 51 + await Promise.all([props.onChange(e)]); 52 + }} 53 + /> 54 + ); 55 + }); 56 + 57 AutosizeTextarea.displayName = "Textarea";
+4 -1
src/replicache/clientMutationContext.ts
··· 16 undoManager, 17 ignoreUndo, 18 defaultEntitySet, 19 }: { 20 undoManager: UndoManager; 21 rep: Replicache<ReplicacheMutators>; 22 ignoreUndo: boolean; 23 defaultEntitySet: string; 24 }, 25 ) { 26 let ctx: MutationContext = { 27 async runOnServer(cb) {}, 28 async runOnClient(cb) { 29 let supabase = supabaseBrowserClient(); 30 - return cb({ supabase }); 31 }, 32 async createEntity({ entityID }) { 33 tx.set(entityID, true);
··· 16 undoManager, 17 ignoreUndo, 18 defaultEntitySet, 19 + permission_token_id, 20 }: { 21 undoManager: UndoManager; 22 rep: Replicache<ReplicacheMutators>; 23 ignoreUndo: boolean; 24 defaultEntitySet: string; 25 + permission_token_id: string; 26 }, 27 ) { 28 let ctx: MutationContext = { 29 + permission_token_id, 30 async runOnServer(cb) {}, 31 async runOnClient(cb) { 32 let supabase = supabaseBrowserClient(); 33 + return cb({ supabase, tx }); 34 }, 35 async createEntity({ entityID }) { 36 tx.set(entityID, true);
+1
src/replicache/index.tsx
··· 116 await mutations[m as keyof typeof mutations]( 117 args, 118 clientMutationContext(tx, { 119 undoManager, 120 rep: newRep, 121 ignoreUndo: args.ignoreUndo || tx.reason !== "initial",
··· 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",
+24 -2
src/replicache/mutations.ts
··· 1 - import { DeepReadonly, Replicache } from "replicache"; 2 import type { Fact, ReplicacheMutators } from "."; 3 import type { Attribute, Attributes, FilterAttributes } from "./attributes"; 4 import { SupabaseClient } from "@supabase/supabase-js"; ··· 6 import { generateKeyBetween } from "fractional-indexing"; 7 8 export type MutationContext = { 9 createEntity: (args: { 10 entityID: string; 11 permission_set: string; ··· 25 cb: (ctx: { supabase: SupabaseClient<Database> }) => Promise<void>, 26 ): Promise<void>; 27 runOnClient( 28 - cb: (ctx: { supabase: SupabaseClient<Database> }) => Promise<void>, 29 ): Promise<void>; 30 }; 31 ··· 604 await ctx.deleteEntity(args.optionEntity); 605 }; 606 607 export const mutations = { 608 retractAttribute, 609 addBlock, ··· 626 createEntity, 627 addPollOption, 628 removePollOption, 629 };
··· 1 + import { DeepReadonly, Replicache, WriteTransaction } from "replicache"; 2 import type { Fact, ReplicacheMutators } from "."; 3 import type { Attribute, Attributes, FilterAttributes } from "./attributes"; 4 import { SupabaseClient } from "@supabase/supabase-js"; ··· 6 import { generateKeyBetween } from "fractional-indexing"; 7 8 export type MutationContext = { 9 + permission_token_id: string; 10 createEntity: (args: { 11 entityID: string; 12 permission_set: string; ··· 26 cb: (ctx: { supabase: SupabaseClient<Database> }) => Promise<void>, 27 ): Promise<void>; 28 runOnClient( 29 + cb: (ctx: { 30 + supabase: SupabaseClient<Database>; 31 + tx: WriteTransaction; 32 + }) => Promise<void>, 33 ): Promise<void>; 34 }; 35 ··· 608 await ctx.deleteEntity(args.optionEntity); 609 }; 610 611 + const updatePublicationDraft: Mutation<{ 612 + title: string; 613 + description: string; 614 + }> = async (args, ctx) => { 615 + await ctx.runOnServer(async (serverCtx) => { 616 + console.log("updating"); 617 + await serverCtx.supabase 618 + .from("leaflets_in_publications") 619 + .update({ description: args.description, title: args.title }) 620 + .eq("leaflet", ctx.permission_token_id); 621 + }); 622 + await ctx.runOnClient(async ({ tx }) => { 623 + await tx.set("publication_title", args.title); 624 + await tx.set("publication_description", args.description); 625 + }); 626 + }; 627 + 628 export const mutations = { 629 retractAttribute, 630 addBlock, ··· 647 createEntity, 648 addPollOption, 649 removePollOption, 650 + updatePublicationDraft, 651 };
+2
src/replicache/serverMutationContext.ts
··· 12 import { v7 } from "uuid"; 13 export function serverMutationContext( 14 tx: PgTransaction<any, any, any>, 15 token_rights: PermissionToken["permission_token_rights"], 16 ) { 17 let ctx: MutationContext & { 18 checkPermission: (entity: string) => Promise<boolean>; 19 } = { 20 async runOnServer(cb) { 21 let supabase = createClient<Database>( 22 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
··· 12 import { v7 } from "uuid"; 13 export function serverMutationContext( 14 tx: PgTransaction<any, any, any>, 15 + permission_token_id: string, 16 token_rights: PermissionToken["permission_token_rights"], 17 ) { 18 let ctx: MutationContext & { 19 checkPermission: (entity: string) => Promise<boolean>; 20 } = { 21 + permission_token_id, 22 async runOnServer(cb) { 23 let supabase = createClient<Database>( 24 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
+1
supabase/database.types.ts
··· 850 pull_result: { 851 client_groups: Json | null 852 facts: Json | null 853 } 854 } 855 }
··· 850 pull_result: { 851 client_groups: Json | null 852 facts: Json | null 853 + publications: Json | null 854 } 855 } 856 }
+32
supabase/migrations/20250606211551_add_pub_data_to_pull_result.sql
···
··· 1 + alter type public."pull_result" add attribute "publications" json; 2 + 3 + set check_function_bodies = off; 4 + 5 + CREATE OR REPLACE FUNCTION public.pull_data(token_id uuid, client_group_id text) 6 + RETURNS pull_result 7 + LANGUAGE plpgsql 8 + AS $function$DECLARE 9 + result pull_result; 10 + BEGIN 11 + -- Get client group data as JSON array 12 + SELECT json_agg(row_to_json(rc)) 13 + FROM replicache_clients rc 14 + WHERE rc.client_group = client_group_id 15 + INTO result.client_groups; 16 + 17 + -- Get facts as JSON array 18 + SELECT json_agg(row_to_json(f)) 19 + FROM permission_tokens pt, 20 + get_facts(pt.root_entity) f 21 + WHERE pt.id = token_id 22 + INTO result.facts; 23 + 24 + -- Get publication data 25 + SELECT json_agg(row_to_json(lip)) 26 + FROM leaflets_in_publications lip 27 + WHERE lip.leaflet = token_id 28 + INTO result.publications; 29 + 30 + RETURN result; 31 + END;$function$ 32 + ;