a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

simplify some stuff

Jared Pereira (May 29, 2026, 4:34 PM EDT) eb0191cd bf8b40da

+211 -371
+13 -69
actions/publications/contributors.ts
··· 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { Ok, Err, type Result } from "src/result"; 6 6 import { revalidatePath } from "next/cache"; 7 - import { getProfiles, idResolver, type Profile } from "src/identity"; 7 + import { idResolver } from "src/identity"; 8 + import { withProfiles, type Contributor } from "./contributorProfiles"; 8 9 9 10 export type ContributorActionError = 10 11 | "unauthorized" ··· 16 17 | "not_invited" 17 18 | "database_error"; 18 19 19 - export type ContributorRow = { 20 - contributor_did: string; 21 - confirmed: boolean; 22 - created_at: string; 23 - handle: string | null; 24 - display_name: string | null; 25 - avatar: string | null; 26 - }; 27 - 28 20 async function loadPublication(publication_uri: string) { 29 21 let { data } = await supabaseServerClient 30 22 .from("publications") ··· 57 49 58 50 export async function listContributors( 59 51 publication_uri: string, 60 - ): Promise<Result<ContributorRow[], ContributorActionError>> { 52 + ): Promise<Result<Contributor[], ContributorActionError>> { 61 53 let identity = await getIdentityData(); 62 54 if (!identity?.atp_did) return Err("unauthorized"); 63 55 ··· 86 78 return Err("database_error"); 87 79 } 88 80 89 - let contributors = data ?? []; 90 - let profiles = await getProfiles(contributors.map((c) => c.contributor_did)); 91 - 92 - let rows: ContributorRow[] = contributors.map((c) => 93 - buildRow( 94 - { 95 - contributor_did: c.contributor_did, 96 - confirmed: c.confirmed, 97 - created_at: c.created_at, 98 - }, 99 - profiles.get(c.contributor_did) ?? null, 100 - ), 101 - ); 102 - 103 - return Ok(rows); 104 - } 105 - 106 - export function buildRow( 107 - input: { contributor_did: string; confirmed: boolean; created_at: string }, 108 - profile: Profile | null, 109 - ): ContributorRow { 110 - return { 111 - contributor_did: input.contributor_did, 112 - confirmed: input.confirmed, 113 - created_at: input.created_at, 114 - handle: profile?.handle ?? null, 115 - display_name: profile?.displayName ?? null, 116 - avatar: profile?.avatar ?? null, 117 - }; 81 + return Ok(await withProfiles(data ?? [])); 118 82 } 119 83 120 84 export async function inviteContributor( 121 85 publication_uri: string, 122 86 handle: string, 123 - ): Promise<Result<ContributorRow, ContributorActionError>> { 87 + ): Promise<Result<Contributor, ContributorActionError>> { 124 88 let identity = await getIdentityData(); 125 89 if (!identity?.atp_did) return Err("unauthorized"); 126 90 ··· 144 108 145 109 await ensureIdentity(did); 146 110 147 - let { error } = await supabaseServerClient 111 + // Insert and read the row back so created_at/confirmed come from the DB. 112 + let { data: inserted, error } = await supabaseServerClient 148 113 .from("publication_contributors") 149 - .insert({ 150 - publication_uri, 151 - contributor_did: did, 152 - confirmed: false, 153 - }); 154 - if (error) { 114 + .insert({ publication_uri, contributor_did: did, confirmed: false }) 115 + .select("contributor_did, confirmed, created_at") 116 + .single(); 117 + if (error || !inserted) { 155 118 console.error("[contributors] insert failed:", error); 156 119 return Err("database_error"); 157 120 } 158 121 159 - // Try to enrich with profile 160 - let profiles = await getProfiles([did]); 161 - 162 - return Ok( 163 - buildRow( 164 - { 165 - contributor_did: did, 166 - confirmed: false, 167 - created_at: new Date().toISOString(), 168 - }, 169 - profiles.get(did) ?? null, 170 - ), 171 - ); 122 + let [row] = await withProfiles([inserted]); 123 + return Ok(row); 172 124 } 173 125 174 126 export async function removeContributor( ··· 240 192 revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 241 193 return Ok(null); 242 194 } 243 - 244 - export async function leavePublication( 245 - publication_uri: string, 246 - ): Promise<Result<null, ContributorActionError>> { 247 - let identity = await getIdentityData(); 248 - if (!identity?.atp_did) return Err("unauthorized"); 249 - return removeContributor(publication_uri, identity.atp_did); 250 - }
+32 -180
actions/publications/draftContributors.ts
··· 3 3 import { getIdentityData } from "actions/getIdentityData"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { Ok, Err, type Result } from "src/result"; 6 - import { revalidatePath } from "next/cache"; 7 - import { getProfiles } from "src/identity"; 8 - import { buildRow, type ContributorRow } from "./contributors"; 6 + import { withProfiles, type Contributor } from "./contributorProfiles"; 9 7 10 8 export type DraftContributorError = 11 9 | "unauthorized" 12 10 | "draft_not_found" 13 11 | "not_a_publication_contributor" 14 - | "invalid_contributor" 15 12 | "database_error"; 16 13 17 - // A draft-contributor candidate is a publication contributor row plus a flag 18 - // marking the publication owner (who is always a candidate but has no 19 - // publication_contributors row of their own). 20 - export type DraftContributorCandidate = ContributorRow & { 21 - is_owner: boolean; 22 - }; 23 - 24 - export type DraftContributorsData = { 25 - owner_did: string; 26 - selected_dids: string[]; 27 - candidates: DraftContributorCandidate[]; 28 - }; 14 + // A draft-contributor candidate is a publication contributor (or the owner, 15 + // who is always a candidate but has no publication_contributors row of their 16 + // own) enriched with profile + an owner flag. 17 + export type DraftContributorCandidate = Contributor & { is_owner: boolean }; 29 18 30 19 // Resolve the publication that owns a draft and authorize the current caller as 31 20 // either the publication owner or a confirmed contributor. Returns the ··· 34 23 leaflet_id: string, 35 24 callerDid: string, 36 25 ): Promise< 37 - Result< 38 - { publication_uri: string; owner_did: string }, 39 - DraftContributorError 40 - > 26 + Result<{ publication_uri: string; owner_did: string }, DraftContributorError> 41 27 > { 42 28 let { data: link } = await supabaseServerClient 43 29 .from("leaflets_in_publications") ··· 46 32 .maybeSingle(); 47 33 if (!link || !link.publication) return Err("draft_not_found"); 48 34 49 - let ownerDid = (link.publications as any)?.identity_did as string | undefined; 35 + let ownerDid = link.publications?.identity_did; 50 36 if (!ownerDid) return Err("draft_not_found"); 51 37 52 38 if (ownerDid !== callerDid) { ··· 62 48 return Ok({ publication_uri: link.publication, owner_did: ownerDid }); 63 49 } 64 50 65 - export async function addSelfAsDraftContributor( 66 - leaflet_id: string, 67 - ): Promise<Result<null, DraftContributorError>> { 68 - let identity = await getIdentityData(); 69 - if (!identity?.atp_did) return Err("unauthorized"); 70 - 71 - // authorizeDraftAccess validates the draft exists and the caller is the 72 - // owner or a confirmed publication contributor. 73 - let access = await authorizeDraftAccess(leaflet_id, identity.atp_did); 74 - if (!access.ok) return access; 75 - // Owners are implicitly already authorized; no need to track as contributor 76 - if (access.value.owner_did === identity.atp_did) return Ok(null); 77 - 78 - let { error } = await supabaseServerClient 79 - .from("leaflet_contributors") 80 - .upsert( 81 - { leaflet: leaflet_id, contributor_did: identity.atp_did }, 82 - { onConflict: "leaflet,contributor_did", ignoreDuplicates: true }, 83 - ); 84 - if (error) { 85 - console.error("[draftContributors] add self failed:", error); 86 - return Err("database_error"); 87 - } 88 - revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 89 - return Ok(null); 90 - } 91 - 92 - // List the draft's current contributor dids plus the set of people who are 93 - // eligible to be contributors (the publication owner + confirmed publication 94 - // contributors), enriched with profile info for rendering. 95 - export async function listDraftContributors( 51 + // List the people eligible to be draft contributors (the publication owner + 52 + // confirmed publication contributors), enriched with profile info. The set of 53 + // *selected* contributors lives in Replicache (the "draft_contributors" key), 54 + // so this only provides the candidate list for the picker + byline rendering. 55 + export async function listDraftContributorCandidates( 96 56 leaflet_id: string, 97 - ): Promise<Result<DraftContributorsData, DraftContributorError>> { 57 + ): Promise<Result<DraftContributorCandidate[], DraftContributorError>> { 98 58 let identity = await getIdentityData(); 99 59 if (!identity?.atp_did) return Err("unauthorized"); 100 60 ··· 102 62 if (!access.ok) return access; 103 63 let { publication_uri, owner_did } = access.value; 104 64 105 - // These two reads are independent, so run them concurrently. 106 - let [ 107 - { data: selectedRows, error: selectedError }, 108 - { data: pubContributors, error: pubError }, 109 - ] = await Promise.all([ 110 - supabaseServerClient 111 - .from("leaflet_contributors") 112 - .select("contributor_did") 113 - .eq("leaflet", leaflet_id), 114 - supabaseServerClient 115 - .from("publication_contributors") 116 - .select("contributor_did, created_at") 117 - .eq("publication_uri", publication_uri) 118 - .eq("confirmed", true) 119 - .order("created_at", { ascending: true }), 120 - ]); 121 - if (selectedError) { 122 - console.error("[draftContributors] list selected failed:", selectedError); 123 - return Err("database_error"); 124 - } 65 + let { data: pubContributors, error: pubError } = await supabaseServerClient 66 + .from("publication_contributors") 67 + .select("contributor_did, confirmed, created_at") 68 + .eq("publication_uri", publication_uri) 69 + .eq("confirmed", true) 70 + .order("created_at", { ascending: true }); 125 71 if (pubError) { 126 72 console.error("[draftContributors] list contributors failed:", pubError); 127 73 return Err("database_error"); 128 74 } 129 75 130 76 // Candidate rows = owner first (synthesized, no publication_contributors row), 131 - // then confirmed contributors (deduped). created_at is only used for ordering, 132 - // which the query already applies; the owner has no row so we use "now". 133 - let candidateRows: { contributor_did: string; created_at: string }[] = [ 134 - { contributor_did: owner_did, created_at: new Date(0).toISOString() }, 77 + // then confirmed contributors (deduped). created_at only drives ordering, 78 + // which the query already applied; the owner has no row so we use epoch. 79 + let candidateRows = [ 80 + { 81 + contributor_did: owner_did, 82 + confirmed: true, 83 + created_at: new Date(0).toISOString(), 84 + }, 85 + ...(pubContributors ?? []).filter((r) => r.contributor_did !== owner_did), 135 86 ]; 136 - let seen = new Set<string>([owner_did]); 137 - for (let row of pubContributors ?? []) { 138 - if (seen.has(row.contributor_did)) continue; 139 - seen.add(row.contributor_did); 140 - candidateRows.push(row); 141 - } 142 87 143 - let profiles = await getProfiles(candidateRows.map((r) => r.contributor_did)); 144 - let candidates: DraftContributorCandidate[] = candidateRows.map((row) => ({ 145 - ...buildRow( 146 - { 147 - contributor_did: row.contributor_did, 148 - confirmed: true, 149 - created_at: row.created_at, 150 - }, 151 - profiles.get(row.contributor_did) ?? null, 152 - ), 153 - is_owner: row.contributor_did === owner_did, 154 - })); 155 - 156 - return Ok({ 157 - owner_did, 158 - selected_dids: (selectedRows ?? []).map((r) => r.contributor_did), 159 - candidates, 160 - }); 161 - } 162 - 163 - // Add a single did as a draft contributor. The did must be the publication 164 - // owner or a confirmed publication contributor. 165 - export async function addDraftContributor( 166 - leaflet_id: string, 167 - contributor_did: string, 168 - ): Promise<Result<null, DraftContributorError>> { 169 - let identity = await getIdentityData(); 170 - if (!identity?.atp_did) return Err("unauthorized"); 171 - 172 - let access = await authorizeDraftAccess(leaflet_id, identity.atp_did); 173 - if (!access.ok) return access; 174 - let { publication_uri, owner_did } = access.value; 175 - 176 - if (contributor_did !== owner_did) { 177 - let { data: pubContrib } = await supabaseServerClient 178 - .from("publication_contributors") 179 - .select("confirmed") 180 - .eq("publication_uri", publication_uri) 181 - .eq("contributor_did", contributor_did) 182 - .maybeSingle(); 183 - if (!pubContrib?.confirmed) return Err("invalid_contributor"); 184 - } 185 - 186 - let { error } = await supabaseServerClient 187 - .from("leaflet_contributors") 188 - .upsert( 189 - { leaflet: leaflet_id, contributor_did }, 190 - { onConflict: "leaflet,contributor_did", ignoreDuplicates: true }, 191 - ); 192 - if (error) { 193 - console.error("[draftContributors] add failed:", error); 194 - return Err("database_error"); 195 - } 196 - revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 197 - return Ok(null); 198 - } 88 + let candidates: DraftContributorCandidate[] = ( 89 + await withProfiles(candidateRows) 90 + ).map((row) => ({ ...row, is_owner: row.contributor_did === owner_did })); 199 91 200 - // Remove a single did from the draft's contributors. 201 - export async function removeDraftContributor( 202 - leaflet_id: string, 203 - contributor_did: string, 204 - ): Promise<Result<null, DraftContributorError>> { 205 - let identity = await getIdentityData(); 206 - if (!identity?.atp_did) return Err("unauthorized"); 207 - 208 - let access = await authorizeDraftAccess(leaflet_id, identity.atp_did); 209 - if (!access.ok) return access; 210 - 211 - let { error } = await supabaseServerClient 212 - .from("leaflet_contributors") 213 - .delete() 214 - .eq("leaflet", leaflet_id) 215 - .eq("contributor_did", contributor_did); 216 - if (error) { 217 - console.error("[draftContributors] remove failed:", error); 218 - return Err("database_error"); 219 - } 220 - revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 221 - return Ok(null); 222 - } 223 - 224 - export async function removeSelfFromDraft( 225 - leaflet_id: string, 226 - ): Promise<Result<null, DraftContributorError>> { 227 - let identity = await getIdentityData(); 228 - if (!identity?.atp_did) return Err("unauthorized"); 229 - 230 - let { error } = await supabaseServerClient 231 - .from("leaflet_contributors") 232 - .delete() 233 - .eq("leaflet", leaflet_id) 234 - .eq("contributor_did", identity.atp_did); 235 - if (error) { 236 - console.error("[draftContributors] remove self failed:", error); 237 - return Err("database_error"); 238 - } 239 - revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); 240 - return Ok(null); 92 + return Ok(candidates); 241 93 }
-38
app/(app)/[leaflet_id]/AutoAddDraftContributorEffect.tsx
··· 1 - "use client"; 2 - 3 - import { useEffect, useRef } from "react"; 4 - import { mutate as swrMutate } from "swr"; 5 - import { useIdentityData } from "components/IdentityProvider"; 6 - import { addSelfAsDraftContributor } from "actions/publications/draftContributors"; 7 - 8 - // When a confirmed publication contributor opens a draft they're not yet 9 - // tracked on, silently register them as a draft contributor so the draft 10 - // appears on their home dashboard. The server action is a no-op for 11 - // non-contributors and for the publication owner. 12 - export function AutoAddDraftContributorEffect(props: { leaflet_id: string }) { 13 - let { identity, mutate } = useIdentityData(); 14 - let ran = useRef<string | null>(null); 15 - 16 - useEffect(() => { 17 - if (!identity?.atp_did) return; 18 - if (ran.current === props.leaflet_id) return; 19 - 20 - let alreadyTracked = (identity.contributor_leaflets ?? []).some( 21 - (row) => row.permission_tokens.id === props.leaflet_id, 22 - ); 23 - let alreadyOwnedOnHome = identity.permission_token_on_homepage.some( 24 - (ptoh) => ptoh.permission_tokens.id === props.leaflet_id, 25 - ); 26 - if (alreadyTracked || alreadyOwnedOnHome) return; 27 - 28 - ran.current = props.leaflet_id; 29 - addSelfAsDraftContributor(props.leaflet_id).then((res) => { 30 - if (res.ok) { 31 - mutate(); 32 - swrMutate("leaflets"); 33 - } 34 - }); 35 - }, [identity, props.leaflet_id, mutate]); 36 - 37 - return null; 38 - }
-2
app/(app)/[leaflet_id]/Leaflet.tsx
··· 14 14 import { LeafletLayout } from "components/LeafletLayout"; 15 15 import { WelcomeModal } from "./WelcomeModal"; 16 16 import { AddToHomeEffect } from "./AddToHomeEffect"; 17 - import { AutoAddDraftContributorEffect } from "./AutoAddDraftContributorEffect"; 18 17 19 18 export function Leaflet(props: { 20 19 token: PermissionToken; ··· 42 41 <UpdateLeafletTitle entityID={props.leaflet_id} /> 43 42 <WelcomeModal /> 44 43 <AddToHomeEffect /> 45 - <AutoAddDraftContributorEffect leaflet_id={props.leaflet_id} /> 46 44 <SelectionManager /> 47 45 {/* we need the padding bottom here because if we don't have it the mobile footer will cut off... 48 46 the dropshadow on the page... the padding is compensated by a negative top margin in mobile footer */}
+24 -14
app/(app)/lish/[did]/[publication]/dashboard/settings/ContributorSettings.tsx
··· 22 22 listContributors, 23 23 removeContributor, 24 24 type ContributorActionError, 25 - type ContributorRow, 26 25 } from "actions/publications/contributors"; 27 - import { leavePublication } from "actions/publications/contributors"; 26 + import type { OkValue } from "src/result"; 28 27 import { UpgradeToProButton } from "../../UpgradeModal"; 29 28 import { getBasePublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 30 29 ··· 71 70 acceptLink={acceptLink} 72 71 /> 73 72 ) : ( 74 - <ContributorLeaveSettings publicationUri={publicationUri} /> 73 + <ContributorLeaveSettings 74 + publicationUri={publicationUri} 75 + myDid={identity?.atp_did} 76 + /> 75 77 ); 76 78 } 77 79 ··· 106 108 mutate((prev) => [...(prev ?? []), res.value], { revalidate: false }); 107 109 toaster({ 108 110 type: "success", 109 - content: `Invited @${res.value.handle ?? "contributor"}`, 111 + content: `Invited @${res.value.profile?.handle ?? "contributor"}`, 110 112 }); 111 113 return true; 112 114 }; 113 115 114 116 let handleRemove = async (contributor_did: string) => { 115 - let removed: ContributorRow | undefined; 117 + let removed: NonNullable<typeof data>[number] | undefined; 116 118 mutate( 117 119 (prev) => { 118 120 let rows = prev ?? []; ··· 168 170 } 169 171 170 172 function ContributorList(props: { 171 - rows: ContributorRow[]; 173 + rows: OkValue<Awaited<ReturnType<typeof listContributors>>>; 172 174 loading: boolean; 173 175 onRemove?: (did: string) => void; 174 176 acceptLink?: string; ··· 204 206 className="flex items-center gap-2 border border-border-light rounded-md px-2 py-1.5" 205 207 > 206 208 <Avatar 207 - src={row.avatar ?? undefined} 208 - displayName={row.display_name ?? row.handle ?? undefined} 209 + src={row.profile?.avatar ?? undefined} 210 + displayName={ 211 + row.profile?.displayName ?? row.profile?.handle ?? undefined 212 + } 209 213 size="medium" 210 214 /> 211 215 <div className="flex flex-col min-w-0 grow"> 212 216 <div className="truncate font-bold text-primary text-sm"> 213 - {row.display_name || row.handle || row.contributor_did} 217 + {row.profile?.displayName || 218 + row.profile?.handle || 219 + row.contributor_did} 214 220 </div> 215 - {row.handle && ( 221 + {row.profile?.handle && ( 216 222 <div className="truncate text-tertiary text-xs italic"> 217 - @{row.handle} 223 + @{row.profile.handle} 218 224 </div> 219 225 )} 220 226 </div> ··· 376 382 ); 377 383 } 378 384 379 - function ContributorLeaveSettings(props: { publicationUri: string }) { 385 + function ContributorLeaveSettings(props: { 386 + publicationUri: string; 387 + myDid: string | null | undefined; 388 + }) { 380 389 let toaster = useToaster(); 381 390 let [leaving, setLeaving] = useState(false); 382 391 let [open, setOpen] = useState(false); 383 392 384 393 let handleLeave = async () => { 385 - if (leaving) return; 394 + if (leaving || !props.myDid) return; 386 395 setLeaving(true); 387 - let res = await leavePublication(props.publicationUri); 396 + // Leaving == removing yourself; removeContributor permits self-removal. 397 + let res = await removeContributor(props.publicationUri, props.myDid); 388 398 setLeaving(false); 389 399 if (!res.ok) { 390 400 toaster({ type: "error", content: ERROR_MESSAGES[res.error] });
+2
app/api/rpc/[command]/pull.ts
··· 78 78 cover_image: string | null; 79 79 preferences: Json | null; 80 80 }[]; 81 + let draft_contributors = (data.draft_contributors as string[] | null) ?? []; 81 82 let pub_patch = publication_data?.[0] 82 83 ? [ 83 84 { ··· 136 137 } as const; 137 138 }), 138 139 ...pub_patch, 140 + { op: "put", key: "draft_contributors", value: draft_contributors }, 139 141 ], 140 142 } as PullResponseV1; 141 143 },
+35 -68
components/Pages/DraftContributorSelector.tsx
··· 1 1 "use client"; 2 2 3 - import useSWR, { useSWRConfig } from "swr"; 3 + import useSWR from "swr"; 4 4 import { Avatar } from "components/Avatar"; 5 5 import { ButtonSecondary } from "components/Buttons"; 6 6 import { Popover } from "components/Popover"; 7 7 import { CheckTiny } from "components/Icons/CheckTiny"; 8 8 import { useEntitySetContext } from "components/EntitySetProvider"; 9 - import { 10 - listDraftContributors, 11 - addDraftContributor, 12 - removeDraftContributor, 13 - type DraftContributorCandidate, 14 - type DraftContributorsData, 15 - } from "actions/publications/draftContributors"; 9 + import { useReplicache } from "src/replicache"; 10 + import { useSubscribe } from "src/replicache/useSubscribe"; 11 + import { listDraftContributorCandidates } from "actions/publications/draftContributors"; 16 12 import { bylineName } from "src/utils/byline"; 17 13 18 - // The byline always shows the PERSON: displayName -> handle -> did fallback, 19 - // resolved via the profile cache like every other candidate. The owner is 20 - // rendered as a person too (never the publication name). 21 - function candidateName(c: DraftContributorCandidate) { 22 - return bylineName({ 23 - did: c.contributor_did, 24 - handle: c.handle, 25 - displayName: c.display_name, 26 - }); 27 - } 28 - 29 14 export function DraftContributorSelector(props: { leaflet_id: string }) { 30 15 let { permissions } = useEntitySetContext(); 31 - let { cache } = useSWRConfig(); 32 - let swrKey = `draft-contributors-${props.leaflet_id}`; 33 - let { data, mutate } = useSWR(swrKey, async () => { 34 - let res = await listDraftContributors(props.leaflet_id); 35 - if (!res.ok) return null; 36 - return res.value; 37 - }); 16 + let { rep } = useReplicache(); 38 17 39 - let candidates = data?.candidates ?? []; 40 - let selectedDids = data?.selected_dids ?? []; 18 + // Selected contributors sync live via Replicache; the candidate list (which 19 + // needs Bluesky profile enrichment) is a read-only fetch. 20 + let selectedDids = 21 + useSubscribe(rep, (tx) => tx.get<string[]>("draft_contributors")) ?? []; 22 + let { data: candidates = [] } = useSWR( 23 + `draft-contributor-candidates-${props.leaflet_id}`, 24 + async () => { 25 + let res = await listDraftContributorCandidates(props.leaflet_id); 26 + return res.ok ? res.value : []; 27 + }, 28 + ); 29 + 30 + // The byline always shows the PERSON: displayName -> handle -> did fallback, 31 + // resolved via the profile cache. The owner is rendered as a person too 32 + // (never the publication name). 33 + let candidateName = (c: (typeof candidates)[number]) => 34 + bylineName( 35 + c.profile ?? { did: c.contributor_did, handle: null, displayName: null }, 36 + ); 41 37 42 38 // Selected contributors, in candidate order. When none are selected the 43 39 // publication owner is the implicit default byline. 44 - let selected = candidates.filter((c) => selectedDids.includes(c.contributor_did)); 40 + let selected = candidates.filter((c) => 41 + selectedDids.includes(c.contributor_did), 42 + ); 45 43 let owner = candidates.find((c) => c.is_owner) ?? null; 46 44 47 - let bylineEntries: DraftContributorCandidate[] = 48 - selected.length > 0 ? selected : owner ? [owner] : []; 45 + let bylineEntries = selected.length > 0 ? selected : owner ? [owner] : []; 49 46 50 - let toggle = async (did: string) => { 51 - // Derive the intended next state from the CURRENT cache value (not the 52 - // render closure) so rapid toggles compose instead of clobbering each 53 - // other. Both the optimistic update and the chosen server action key off 54 - // the live cache, and SWR rolls back on error rather than restoring a 55 - // captured snapshot. 56 - let current = 57 - (cache.get(swrKey)?.data as DraftContributorsData | undefined) 58 - ?.selected_dids ?? []; 59 - let willSelect = !current.includes(did); 60 - await mutate( 61 - async () => { 62 - let res = willSelect 63 - ? await addDraftContributor(props.leaflet_id, did) 64 - : await removeDraftContributor(props.leaflet_id, did); 65 - if (!res.ok) throw new Error(res.error); 66 - // Revalidate (below) reconciles the authoritative server list. 67 - return undefined; 68 - }, 69 - { 70 - optimisticData: (cur) => { 71 - if (!cur) return cur as any; 72 - let sel = cur.selected_dids; 73 - let next = willSelect 74 - ? sel.includes(did) 75 - ? sel 76 - : [...sel, did] 77 - : sel.filter((d) => d !== did); 78 - return { ...cur, selected_dids: next }; 79 - }, 80 - rollbackOnError: true, 81 - populateCache: false, 82 - revalidate: true, 83 - }, 84 - ); 47 + let toggle = (did: string) => { 48 + rep?.mutate.toggleDraftContributor({ 49 + contributor_did: did, 50 + selected: !selectedDids.includes(did), 51 + }); 85 52 }; 86 53 87 54 let byline = ( ··· 92 59 bylineEntries.map((c, i) => ( 93 60 <span key={c.contributor_did} className="flex gap-1 items-center"> 94 61 <Avatar 95 - src={c.avatar ?? undefined} 62 + src={c.profile?.avatar ?? undefined} 96 63 displayName={candidateName(c)} 97 64 size="tiny" 98 65 /> ··· 136 103 {isSelected && <CheckTiny />} 137 104 </div> 138 105 <Avatar 139 - src={c.avatar ?? undefined} 106 + src={c.profile?.avatar ?? undefined} 140 107 displayName={candidateName(c)} 141 108 size="small" 142 109 />
+58
src/replicache/mutations.ts
··· 746 746 }); 747 747 }; 748 748 749 + // Toggle a draft contributor (the byline for this draft). The selected dids 750 + // live in the leaflet_contributors table and are pulled into Replicache under 751 + // the "draft_contributors" key, so the byline syncs live across collaborators. 752 + const toggleDraftContributor: Mutation<{ 753 + contributor_did: string; 754 + selected: boolean; 755 + }> = async (args, ctx) => { 756 + await ctx.runOnServer(async ({ supabase }) => { 757 + let { contributor_did } = args; 758 + let leaflet = ctx.permission_token_id; 759 + 760 + if (!args.selected) { 761 + await supabase 762 + .from("leaflet_contributors") 763 + .delete() 764 + .eq("leaflet", leaflet) 765 + .eq("contributor_did", contributor_did); 766 + return; 767 + } 768 + 769 + // Adding: the target must be the publication owner or a confirmed contributor. 770 + let { data: link } = await supabase 771 + .from("leaflets_in_publications") 772 + .select("publication, publications(identity_did)") 773 + .eq("leaflet", leaflet) 774 + .maybeSingle(); 775 + if (!link?.publication) return; 776 + 777 + let isOwner = contributor_did === link.publications?.identity_did; 778 + if (!isOwner) { 779 + let { data: pubContrib } = await supabase 780 + .from("publication_contributors") 781 + .select("confirmed") 782 + .eq("publication_uri", link.publication) 783 + .eq("contributor_did", contributor_did) 784 + .maybeSingle(); 785 + if (!pubContrib?.confirmed) return; 786 + } 787 + 788 + await supabase 789 + .from("leaflet_contributors") 790 + .upsert( 791 + { leaflet, contributor_did }, 792 + { onConflict: "leaflet,contributor_did", ignoreDuplicates: true }, 793 + ); 794 + }); 795 + await ctx.runOnClient(async ({ tx }) => { 796 + let current = ((await tx.get<string[]>("draft_contributors")) ?? []).slice(); 797 + let next = args.selected 798 + ? current.includes(args.contributor_did) 799 + ? current 800 + : [...current, args.contributor_did] 801 + : current.filter((d) => d !== args.contributor_did); 802 + await tx.set("draft_contributors", next); 803 + }); 804 + }; 805 + 749 806 const createFootnote: Mutation<{ 750 807 footnoteEntityID: string; 751 808 blockID: string; ··· 801 858 removePollOption, 802 859 updatePublicationDraft, 803 860 updateLeafletMetadata, 861 + toggleDraftContributor, 804 862 createFootnote, 805 863 deleteFootnote, 806 864 };
+1
supabase/database.types.ts
··· 1844 1844 client_groups: Json | null 1845 1845 facts: Json | null 1846 1846 publications: Json | null 1847 + draft_contributors: Json | null 1847 1848 } 1848 1849 } 1849 1850 }
+46
supabase/migrations/20260529000000_add_draft_contributors_to_pull.sql
··· 1 + alter type public."pull_result" add attribute "draft_contributors" 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 - try leaflets_in_publications first, then leaflets_to_documents 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 + -- If no publication data found, try leaflets_to_documents (for standalone documents) 31 + IF result.publications IS NULL THEN 32 + SELECT json_agg(row_to_json(ltd)) 33 + FROM leaflets_to_documents ltd 34 + WHERE ltd.leaflet = token_id 35 + INTO result.publications; 36 + END IF; 37 + 38 + -- Get the dids selected as contributors on this draft 39 + SELECT json_agg(lc.contributor_did) 40 + FROM leaflet_contributors lc 41 + WHERE lc.leaflet = token_id 42 + INTO result.draft_contributors; 43 + 44 + RETURN result; 45 + END;$function$ 46 + ;