From ce490260cb07bd860b12b590e8cfd228294decf7 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 6 Jun 2025 18:31:49 -0400 Subject: [PATCH] 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 --- app/api/rpc/[command]/pull.ts | 19 ++++++ app/api/rpc/[command]/push.ts | 3 +- components/Input.tsx | 31 ++++++++- components/Pages/PublicationMetadata.tsx | 68 ++++++++----------- components/utils/AutosizeTextarea.tsx | 35 +++++++++- src/replicache/clientMutationContext.ts | 5 +- src/replicache/index.tsx | 1 + src/replicache/mutations.ts | 26 ++++++- src/replicache/serverMutationContext.ts | 2 + supabase/database.types.ts | 1 + ...0606211551_add_pub_data_to_pull_result.sql | 32 +++++++++ 11 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 supabase/migrations/20250606211551_add_pub_data_to_pull_result.sql diff --git a/app/api/rpc/[command]/pull.ts b/app/api/rpc/[command]/pull.ts index 7b9fa859..647700d6 100644 --- a/app/api/rpc/[command]/pull.ts +++ b/app/api/rpc/[command]/pull.ts @@ -70,6 +70,24 @@ export const pull = makeRoute({ updated_at: string | null; version: number; }[]; + let publication_data = data.publications as { + description: string; + title: string; + }[]; + let pub_patch = publication_data[0] + ? [ + { + op: "put", + key: "publication_description", + value: publication_data[0].description, + }, + { + op: "put", + key: "publication_title", + value: publication_data[0].title, + }, + ] + : []; let clientGroup = ( (data.client_groups as { @@ -98,6 +116,7 @@ export const pull = makeRoute({ value: FactWithIndexes(f as unknown as Fact), } as const; }), + ...pub_patch, ], } as PullResponseV1; }, diff --git a/app/api/rpc/[command]/push.ts b/app/api/rpc/[command]/push.ts index c031fee9..0531ba2a 100644 --- a/app/api/rpc/[command]/push.ts +++ b/app/api/rpc/[command]/push.ts @@ -1,4 +1,3 @@ -import { PushResponse } from "replicache"; import { serverMutationContext } from "src/replicache/serverMutationContext"; import { mutations } from "src/replicache/mutations"; import { eq } from "drizzle-orm"; @@ -79,7 +78,7 @@ export const push = makeRoute({ try { await mutations[name]( mutation.args as any, - serverMutationContext(tx, token_rights), + serverMutationContext(tx, token.id, token_rights), ); } catch (e) { console.log( diff --git a/components/Input.tsx b/components/Input.tsx index 3300144c..382c4b1e 100644 --- a/components/Input.tsx +++ b/components/Input.tsx @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useEffect, useRef, type JSX } from "react"; +import { useEffect, useRef, useState, type JSX } from "react"; import { onMouseDown } from "src/utils/iosInputMouseDown"; import { isIOS } from "src/utils/isDevice"; @@ -29,6 +29,35 @@ export const Input = ( ); }; +export const AsyncValueInput = ( + props: { + textarea?: boolean; + } & JSX.IntrinsicElements["input"] & + JSX.IntrinsicElements["textarea"], +) => { + let [intermediateState, setIntermediateState] = useState( + props.value as string, + ); + + useEffect(() => { + setIntermediateState(props.value as string); + }, [props.value]); + + return ( + { + if (!props.onChange) return; + setIntermediateState(e.currentTarget.value); + await Promise.all([ + props.onChange(e as React.ChangeEvent), + ]); + }} + /> + ); +}; + export const focusElement = (el?: HTMLInputElement | null) => { if (!isIOS()) { el?.focus(); diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx index 9de3c485..97bf4000 100644 --- a/components/Pages/PublicationMetadata.tsx +++ b/components/Pages/PublicationMetadata.tsx @@ -1,54 +1,40 @@ import Link from "next/link"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; -import { Input } from "components/Input"; +import { AsyncValueInput, Input } from "components/Input"; import { useEffect, useState } from "react"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import { updateLeafletDraftMetadata } from "actions/publications/updateLeafletDraftMetadata"; import { useReplicache } from "src/replicache"; -import { useIdentityData } from "components/IdentityProvider"; -import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; +import { + AsyncValueAutosizeTextarea, + AutosizeTextarea, +} from "components/utils/AutosizeTextarea"; import { Separator } from "components/Layout"; import { AtUri } from "@atproto/syntax"; import { PubLeafletDocument } from "lexicons/api"; -import { publications } from "drizzle/schema"; import { getBasePublicationURL, getPublicationURL, } from "app/lish/createPub/getPublicationURL"; +import { useSubscribe } from "src/replicache/useSubscribe"; export const PublicationMetadata = ({ cardBorderHidden, }: { cardBorderHidden: boolean; }) => { - let { permission_token } = useReplicache(); - let { data: pub, mutate } = useLeafletPublicationData(); - let [titleState, setTitleState] = useState(pub?.title || ""); - let [descriptionState, setDescriptionState] = useState( - pub?.description || "", - ); + let { rep } = useReplicache(); + let { data: pub } = useLeafletPublicationData(); + let title = + useSubscribe(rep, (tx) => tx.get("publication_title")) || + pub?.title || + ""; + let description = + useSubscribe(rep, (tx) => tx.get("publication_description")) || + pub?.description || + ""; let record = pub?.documents?.data as PubLeafletDocument.Record | null; let publishedAt = record?.publishedAt; - useEffect(() => { - setTitleState(pub?.title || ""); - setDescriptionState(pub?.description || ""); - }, [pub]); - useDebouncedEffect( - async () => { - if (!pub || !pub.publications) return; - if (pub.title === titleState && pub.description === descriptionState) - return; - await updateLeafletDraftMetadata( - permission_token.id, - pub.publications?.uri, - titleState, - descriptionState, - ); - mutate(); - }, - 1000, - [pub, titleState, descriptionState, permission_token], - ); if (!pub || !pub.publications) return null; return ( @@ -66,20 +52,26 @@ export const PublicationMetadata = ({ Editor - { - setTitleState(e.currentTarget.value); + value={title} + onChange={async (e) => { + await rep?.mutate.updatePublicationDraft({ + title: e.currentTarget.value, + description, + }); }} placeholder="Untitled" /> - { - setDescriptionState(e.currentTarget.value); + value={description} + onChange={async (e) => { + await rep?.mutate.updatePublicationDraft({ + description: e.currentTarget.value, + title, + }); }} /> {pub.doc ? ( diff --git a/components/utils/AutosizeTextarea.tsx b/components/utils/AutosizeTextarea.tsx index 226cef88..551484fc 100644 --- a/components/utils/AutosizeTextarea.tsx +++ b/components/utils/AutosizeTextarea.tsx @@ -1,4 +1,10 @@ -import { forwardRef, useImperativeHandle, useRef } from "react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; import styles from "./textarea-styles.module.css"; type Props = React.DetailedHTMLProps< @@ -21,4 +27,31 @@ export const AutosizeTextarea = forwardRef( ); }, ); + +export const AsyncValueAutosizeTextarea = forwardRef< + HTMLTextAreaElement, + Props +>((props: Props, ref) => { + let [intermediateState, setIntermediateState] = useState( + props.value as string, + ); + + useEffect(() => { + setIntermediateState(props.value as string); + }, [props.value]); + + return ( + { + if (!props.onChange) return; + setIntermediateState(e.currentTarget.value); + await Promise.all([props.onChange(e)]); + }} + /> + ); +}); + AutosizeTextarea.displayName = "Textarea"; diff --git a/src/replicache/clientMutationContext.ts b/src/replicache/clientMutationContext.ts index 6f41b44f..d6c1584f 100644 --- a/src/replicache/clientMutationContext.ts +++ b/src/replicache/clientMutationContext.ts @@ -16,18 +16,21 @@ export function clientMutationContext( undoManager, ignoreUndo, defaultEntitySet, + permission_token_id, }: { undoManager: UndoManager; rep: Replicache; ignoreUndo: boolean; defaultEntitySet: string; + permission_token_id: string; }, ) { let ctx: MutationContext = { + permission_token_id, async runOnServer(cb) {}, async runOnClient(cb) { let supabase = supabaseBrowserClient(); - return cb({ supabase }); + return cb({ supabase, tx }); }, async createEntity({ entityID }) { tx.set(entityID, true); diff --git a/src/replicache/index.tsx b/src/replicache/index.tsx index ddc9d9c0..900d8717 100644 --- a/src/replicache/index.tsx +++ b/src/replicache/index.tsx @@ -116,6 +116,7 @@ export function ReplicacheProvider(props: { await mutations[m as keyof typeof mutations]( args, clientMutationContext(tx, { + permission_token_id: props.token.id, undoManager, rep: newRep, ignoreUndo: args.ignoreUndo || tx.reason !== "initial", diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index 875a9a07..9f694201 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -1,4 +1,4 @@ -import { DeepReadonly, Replicache } from "replicache"; +import { DeepReadonly, Replicache, WriteTransaction } from "replicache"; import type { Fact, ReplicacheMutators } from "."; import type { Attribute, Attributes, FilterAttributes } from "./attributes"; import { SupabaseClient } from "@supabase/supabase-js"; @@ -6,6 +6,7 @@ import { Database } from "supabase/database.types"; import { generateKeyBetween } from "fractional-indexing"; export type MutationContext = { + permission_token_id: string; createEntity: (args: { entityID: string; permission_set: string; @@ -25,7 +26,10 @@ export type MutationContext = { cb: (ctx: { supabase: SupabaseClient }) => Promise, ): Promise; runOnClient( - cb: (ctx: { supabase: SupabaseClient }) => Promise, + cb: (ctx: { + supabase: SupabaseClient; + tx: WriteTransaction; + }) => Promise, ): Promise; }; @@ -604,6 +608,23 @@ const removePollOption: Mutation<{ await ctx.deleteEntity(args.optionEntity); }; +const updatePublicationDraft: Mutation<{ + title: string; + description: string; +}> = async (args, ctx) => { + await ctx.runOnServer(async (serverCtx) => { + console.log("updating"); + await serverCtx.supabase + .from("leaflets_in_publications") + .update({ description: args.description, title: args.title }) + .eq("leaflet", ctx.permission_token_id); + }); + await ctx.runOnClient(async ({ tx }) => { + await tx.set("publication_title", args.title); + await tx.set("publication_description", args.description); + }); +}; + export const mutations = { retractAttribute, addBlock, @@ -626,4 +647,5 @@ export const mutations = { createEntity, addPollOption, removePollOption, + updatePublicationDraft, }; diff --git a/src/replicache/serverMutationContext.ts b/src/replicache/serverMutationContext.ts index 854e800f..756fc9df 100644 --- a/src/replicache/serverMutationContext.ts +++ b/src/replicache/serverMutationContext.ts @@ -12,11 +12,13 @@ import { Database } from "supabase/database.types"; import { v7 } from "uuid"; export function serverMutationContext( tx: PgTransaction, + permission_token_id: string, token_rights: PermissionToken["permission_token_rights"], ) { let ctx: MutationContext & { checkPermission: (entity: string) => Promise; } = { + permission_token_id, async runOnServer(cb) { let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 433f0958..6f7b3c44 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -850,6 +850,7 @@ export type Database = { pull_result: { client_groups: Json | null facts: Json | null + publications: Json | null } } } diff --git a/supabase/migrations/20250606211551_add_pub_data_to_pull_result.sql b/supabase/migrations/20250606211551_add_pub_data_to_pull_result.sql new file mode 100644 index 00000000..cd4b1e12 --- /dev/null +++ b/supabase/migrations/20250606211551_add_pub_data_to_pull_result.sql @@ -0,0 +1,32 @@ +alter type public."pull_result" add attribute "publications" json; + +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.pull_data(token_id uuid, client_group_id text) + RETURNS pull_result + LANGUAGE plpgsql +AS $function$DECLARE + result pull_result; +BEGIN + -- Get client group data as JSON array + SELECT json_agg(row_to_json(rc)) + FROM replicache_clients rc + WHERE rc.client_group = client_group_id + INTO result.client_groups; + + -- Get facts as JSON array + SELECT json_agg(row_to_json(f)) + FROM permission_tokens pt, + get_facts(pt.root_entity) f + WHERE pt.id = token_id + INTO result.facts; + + -- Get publication data + SELECT json_agg(row_to_json(lip)) + FROM leaflets_in_publications lip + WHERE lip.leaflet = token_id + INTO result.publications; + + RETURN result; +END;$function$ +; -- 2.51.2