Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/components/profile/__tests__/endorse-reason-confirm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,38 @@ describe("runEndorseReasonConfirm", () => {
expect(deps.refetchLists).not.toHaveBeenCalled()
expect(deps.setOptimistic).not.toHaveBeenCalledWith(null)
})

// PR #110: the sidebar pushes the new award into the shared
// received-endorsements overlay the instant it lands, via onAwardCreated.
it("fires onAwardCreated with the award before the given-set refetch", async () => {
const order: string[] = []
const deps = makeDeps({
onAwardCreated: vi.fn(() => {
order.push("onAwardCreated")
}),
refetchGiven: vi.fn(async () => {
order.push("refetchGiven")
}),
})

await runEndorseReasonConfirm(deps)

expect(deps.onAwardCreated).toHaveBeenCalledTimes(1)
expect(deps.onAwardCreated).toHaveBeenCalledWith(award)
// The optimistic overlay push must happen before the refetch so the
// counter/Received tab update immediately, not after the slow scan.
expect(order).toEqual(["onAwardCreated", "refetchGiven"])
})

it("does not fire onAwardCreated when the award itself fails", async () => {
const deps = makeDeps({
createAward: vi.fn(async () => {
throw new Error("award failed")
}),
onAwardCreated: vi.fn(),
})

await expect(runEndorseReasonConfirm(deps)).rejects.toThrow("award failed")
expect(deps.onAwardCreated).not.toHaveBeenCalled()
})
})
14 changes: 14 additions & 0 deletions src/components/profile/endorse-reason-confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export interface EndorseReasonConfirmDeps {
listRkey: string | null
/** Creates the endorsement award; resolves to its strong ref. */
createAward: (note: string) => Promise<{ uri: string; cid: string }>
/**
* Optional hook fired the instant the award lands (before the given-set
* refetch and any list-append). PR #110 uses it to push the award into
* the shared received-endorsements optimistic overlay so the subject's
* counter and Received tab update immediately. Never throws the flow.
*/
onAwardCreated?: (award: { uri: string; cid: string }) => void
/** Appends the award to the chosen list. May throw on conflict. */
appendToList: (
listRkey: string,
Expand All @@ -40,6 +47,7 @@ export async function runEndorseReasonConfirm(
note,
listRkey,
createAward,
onAwardCreated,
appendToList,
refetchGiven,
refetchLists,
Expand All @@ -58,6 +66,12 @@ export async function runEndorseReasonConfirm(
throw err
}

// The award is authoritative the moment it lands. Surface it to the
// shared received-overlay (if wired) before anything that can throw,
// so the subject's counter/Received tab update even if the optional
// list-append below fails.
onAwardCreated?.(award)

// The award succeeded and is now authoritative. Refetch the given set
// BEFORE the optional list-append so a failing append can't snap the
// button back to "Endorse" (which would nudge a duplicate award).
Expand Down
4 changes: 4 additions & 0 deletions src/components/profile/profile-endorsements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export default function ProfileEndorsements({ did }: ProfileEndorsementsProps) {
>("hide-rejected")
const [filterOpen, setFilterOpen] = useState(false)

// The optimistic overlay now lives in `useReceivedEndorsements` itself
// (a shared module store), so `received.endorsements` already reflects
// the viewer's own Endorse/Revoke action issued from the sidebar — no
// component-local overlay needed here.
const displayReceived = received.endorsements

// Owner-only response filter on top of `displayReceived`. Foreign
Expand Down
20 changes: 19 additions & 1 deletion src/components/profile/profile-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import { useAuth } from "@/lib/auth/auth-context"
import { useFollowing } from "@/hooks/use-following"
import { useFollowers } from "@/hooks/use-followers"
import { useGivenEndorsements } from "@/hooks/use-endorsements"
import { useReceivedEndorsements } from "@/hooks/use-received-endorsements"
import {
useReceivedEndorsements,
addOptimisticReceivedEndorsement,
removeOptimisticReceivedEndorsement,
} from "@/hooks/use-received-endorsements"
import { useEndorsementLists } from "@/hooks/use-endorsement-lists"
import { useAuthorInfo } from "@/hooks/use-author-info"
import EndorseReasonModal from "@/components/profile/endorse-reason-modal"
Expand Down Expand Up @@ -825,6 +829,19 @@ function EndorseButton({ viewerDid, subjectDid }: EndorseButtonProps) {
note,
listRkey,
createAward: (n) => createEndorsementAward(viewerDid, subjectDid, n),
// PR #110: as soon as the award lands, push it into the shared
// received-endorsements overlay so the subject's "Endorsed by N"
// counter and the Endorsements tab reflect it immediately, ahead
// of the 5-min scan cache / indexer catching up.
onAwardCreated: (award) =>
addOptimisticReceivedEndorsement(subjectDid, {
uri: award.uri,
cid: award.cid,
issuerDid: viewerDid,
createdAt: new Date().toISOString(),
note: note || undefined,
responseState: null,
}),
appendToList: (rkey, award) => appendItemToList(viewerDid, rkey, award),
refetchGiven: () => ownGiven.refetch(),
refetchLists: () => ownLists.refetch(),
Expand All @@ -847,6 +864,7 @@ function EndorseButton({ viewerDid, subjectDid }: EndorseButtonProps) {
setIsWriting(true)
try {
await deleteEndorsementAward(viewerDid, existing.rkey)
removeOptimisticReceivedEndorsement(subjectDid, existing.uri)
await ownGiven.refetch()
setConfirmRevoke(false)
} catch (err) {
Expand Down
110 changes: 105 additions & 5 deletions src/hooks/use-received-endorsements.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
"use client"

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react"

/**
* One endorsement received: who endorsed me, when, and the optional
Expand Down Expand Up @@ -237,6 +244,88 @@ interface CacheEntry {

const cache = new Map<string, CacheEntry>()

// ---------------------------------------------------------------------------
// Shared optimistic overlay, keyed by profileDid. Lets a mutation in one
// component (e.g. the sidebar Endorse button) reflect immediately in every
// other consumer of the same DID's received list — the sidebar "Endorsed by N"
// counter AND the Endorsements tab — without waiting on the 5-min scan cache
// or the indexer to catch up. Mirrors the module-store + useSyncExternalStore
// pattern in endorsement-closure-cache.ts.
//
// Entries are deliberately NOT pruned once the real scan catches up: the merge
// de-dups adds by URI against the scan result and a `hide` is a no-op once the
// award is already gone, so a leftover overlay entry can't double-count or
// resurrect anything. Bounded by user actions.
// ---------------------------------------------------------------------------

interface ReceivedOverlay {
adds: ReceivedEndorsement[]
hides: Set<string>
}

const overlays = new Map<string, ReceivedOverlay>()
let overlayVersion = 0
const overlaySubscribers = new Set<() => void>()

function notifyOverlay(): void {
overlayVersion++
for (const s of overlaySubscribers) s()
}

function subscribeOverlay(cb: () => void): () => void {
overlaySubscribers.add(cb)
return () => {
overlaySubscribers.delete(cb)
}
}

function mergeOverlay(
profileDid: string,
base: ReceivedEndorsement[],
): ReceivedEndorsement[] {
const o = overlays.get(profileDid)
if (!o || (o.adds.length === 0 && o.hides.size === 0)) return base
const filtered = base.filter((e) => !o.hides.has(e.uri))
const seen = new Set(filtered.map((e) => e.uri))
const merged = [...o.adds.filter((e) => !seen.has(e.uri)), ...filtered]
merged.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1))
return merged
}

/**
* Optimistically add a received endorsement for `profileDid` so every
* consumer of `useReceivedEndorsements(profileDid)` reflects it on the next
* render. Call from the success path of a write that issues an endorsement
* targeting `profileDid`. Idempotent by award URI.
*/
export function addOptimisticReceivedEndorsement(
profileDid: string,
entry: ReceivedEndorsement,
): void {
const o = overlays.get(profileDid) ?? { adds: [], hides: new Set<string>() }
o.hides.delete(entry.uri)
if (!o.adds.some((e) => e.uri === entry.uri)) {
o.adds = [entry, ...o.adds]
}
overlays.set(profileDid, o)
notifyOverlay()
}

/**
* Optimistically remove a received endorsement (by award URI) for
* `profileDid`. Call from the success path of a revoke. Idempotent.
*/
export function removeOptimisticReceivedEndorsement(
profileDid: string,
uri: string,
): void {
const o = overlays.get(profileDid) ?? { adds: [], hides: new Set<string>() }
o.adds = o.adds.filter((e) => e.uri !== uri)
o.hides.add(uri)
overlays.set(profileDid, o)
notifyOverlay()
}

/**
* Read the cached scan result for a DID without triggering a
* network fetch. Used by `usePendingAwardsCount` on the nav rail —
Expand All @@ -253,7 +342,7 @@ export function peekCachedReceivedEndorsements(
const entry = cache.get(profileDid)
if (!entry) return null
if (Date.now() - entry.fetchedAt >= STALE_MS) return null
return entry.data
return mergeOverlay(profileDid, entry.data)
}

/**
Expand Down Expand Up @@ -380,11 +469,22 @@ export function useReceivedEndorsements(
// privacy (rejected awards never leaving the indexer for non-owner
// viewers) would require authenticated indexer queries — out of
// scope per the round-1 review B5 resolution.
// Re-render whenever the shared optimistic overlay changes, so a write
// in a sibling component (e.g. the sidebar Endorse button) flows into
// this consumer's count/list immediately.
const overlaySnapshot = useSyncExternalStore(
subscribeOverlay,
() => overlayVersion,
() => overlayVersion,
)

const includeRejected = opts?.includeRejected ?? false
const endorsements = useMemo(() => {
if (includeRejected) return scanResult
return scanResult.filter((e) => e.responseState !== "rejected")
}, [scanResult, includeRejected])
void overlaySnapshot
const base = profileDid ? mergeOverlay(profileDid, scanResult) : scanResult
if (includeRejected) return base
return base.filter((e) => e.responseState !== "rejected")
}, [scanResult, includeRejected, profileDid, overlaySnapshot])

return {
endorsements,
Expand Down