this repo has no description
1
fork

Configure Feed

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

Polish sharing: useInbox hook, cancelled guard, share cache, permanent doc errors

- Extract web-local useInbox hook (apps/web/src/hooks/use-inbox.ts) using
getOpake() singleton; bootstraps InboxKeeper exactly once across concurrent
mounts; wires into shared.lazy.tsx replacing inline subscription logic (#15/#18)
- Add per-effect-invocation cancelled flag to shared.lazy.tsx async batch
resolver, preventing setState on stale/unmounted invocations (#17)
- Cache getActiveFileManager().listShares() in ShareManagementDialog with a
30s TTL; invalidated on revoke so repeated dialog opens skip the round-trip (#12)
- Track permanent document errors (NotFound/InvalidRecord/Decryption/Serialization)
across a retry_pending_shares pass; subsequent pending shares for the same
broken document skip the PDS round-trip (#13)
- Rename ShareDialog local `handle` variable to `recipient`; resolved identity
renamed to `resolved` to eliminate the shadow (#16)

+158 -31
+7 -7
apps/web/src/components/cabinet/ShareDialog.tsx
··· 50 50 const handleShare = useCallback(async () => { 51 51 if (!documentUri || session.status !== "active" || !recipientHandle.trim()) return; 52 52 53 - const handle = recipientHandle.trim(); 53 + const recipient = recipientHandle.trim(); 54 54 55 55 setStatus("resolving"); 56 56 setErrorMessage(""); ··· 58 58 try { 59 59 // Resolve recipient — may throw RecipientNotReadyError 60 60 try { 61 - const recipient = await resolveRecipient(handle); 61 + const resolved = await resolveRecipient(recipient); 62 62 63 - if (recipient.did === session.did) { 63 + if (resolved.did === session.did) { 64 64 throw new Error("You can't share a file with yourself"); 65 65 } 66 66 67 67 setStatus("sharing"); 68 68 69 69 // Core handles: fetch document → unwrap key → wrap to recipient → create grant 70 - await getActiveFileManager().share(documentUri, recipient.did, recipient.publicKey, "read"); 70 + await getActiveFileManager().share(documentUri, resolved.did, resolved.publicKey, "read"); 71 71 } catch (resolveError) { 72 72 if (resolveError instanceof RecipientNotReadyError) { 73 - await getActiveFileManager().createPendingShare(documentUri, handle, "read", null); 73 + await getActiveFileManager().createPendingShare(documentUri, recipient, "read", null); 74 74 setStatus("done"); 75 75 toastSuccess( 76 - `${handle} hasn't set up Opake yet. Share queued — completes automatically once they log in (expires in 7 days).`, 76 + `${recipient} hasn't set up Opake yet. Share queued — completes automatically once they log in (expires in 7 days).`, 77 77 ); 78 78 dismiss(); 79 79 return; ··· 91 91 })); 92 92 93 93 setStatus("done"); 94 - toastSuccess(`Shared "${documentName}" with ${handle}`); 94 + toastSuccess(`Shared "${documentName}" with ${recipient}`); 95 95 dismiss(); 96 96 } catch (error) { 97 97 const message = error instanceof Error ? error.message : "Failed to share";
+30 -1
apps/web/src/components/cabinet/ShareManagementDialog.tsx
··· 5 5 import { getActiveFileManager } from "@/stores/documents/store"; 6 6 import { toastError, toastSuccess } from "@/stores/toast"; 7 7 8 + // Module-level cache so repeated dialog opens don't each issue a full 9 + // `listShares` round-trip. Invalidated after any revoke so the next open 10 + // reflects the authoritative server state. 11 + interface SharesCache { 12 + readonly all: readonly GrantEntry[]; 13 + readonly ts: number; 14 + } 15 + const SHARES_CACHE_TTL_MS = 30_000; 16 + // Wrapped in a const object because functional/no-let prohibits module-level `let`. 17 + const _sharesCache = { value: null as SharesCache | null }; 18 + 19 + async function fetchAllShares(): Promise<readonly GrantEntry[]> { 20 + const now = Date.now(); 21 + if (_sharesCache.value && now - _sharesCache.value.ts < SHARES_CACHE_TTL_MS) { 22 + return _sharesCache.value.all; 23 + } 24 + const all = await getActiveFileManager().listShares(); 25 + // eslint-disable-next-line functional/immutable-data -- module-level cache for repeated dialog opens 26 + _sharesCache.value = { all, ts: Date.now() }; 27 + return all; 28 + } 29 + 30 + function invalidateSharesCache(): void { 31 + // eslint-disable-next-line functional/immutable-data -- module-level cache cleanup 32 + _sharesCache.value = null; 33 + } 34 + 8 35 export interface ShareManagementDialogHandle { 9 36 readonly show: (documentUri: string, documentName: string) => void; 10 37 } ··· 30 57 const loadShares = useCallback(async (uri: string) => { 31 58 setLoading(true); 32 59 try { 33 - const all = await getActiveFileManager().listShares(); 60 + const all = await fetchAllShares(); 34 61 setShares(all.filter((g) => g.document === uri)); 35 62 } catch (err) { 36 63 toastError(`Failed to load shares: ${err instanceof Error ? err.message : String(err)}`); ··· 51 78 // reconcile via the InboxKeeper on peer devices, but the current 52 79 // user's dialog needs the entry gone now. 53 80 setShares((prev) => prev.filter((g) => g.uri !== grantUri)); 81 + // Invalidate so the next dialog open re-fetches the authoritative list. 82 + invalidateSharesCache(); 54 83 toastSuccess("Access revoked"); 55 84 } catch (err) { 56 85 toastError(`Failed to revoke: ${err instanceof Error ? err.message : String(err)}`);
+62
apps/web/src/hooks/use-inbox.ts
··· 1 + // useInbox — subscription-based inbox hook for the web app. 2 + // 3 + // Mirrors `@opake/react`'s useInbox but uses the module-level `getOpake()` 4 + // singleton rather than the `OpakeProvider` context (which isn't wired up 5 + // in this app yet). 6 + // 7 + // Behaviour: 8 + // - Installs a watcher on mount; closes it on unmount 9 + // - Bootstraps via `opake.listInbox()` exactly once across concurrent mounts 10 + // - Subsequent SSE `grant:upsert` / `grant:delete` events patch the keeper 11 + // - First watcher fire with `loaded = true` (warm restart) is fast-path — 12 + // no bootstrap needed 13 + 14 + import { useEffect, useState } from "react"; 15 + import type { InboxGrant, InboxSnapshot } from "@opake/sdk"; 16 + import { getOpake } from "@/stores/auth"; 17 + 18 + // Wrapped in a const object because functional/no-let prohibits module-level `let`. 19 + const _bootstrap = { promise: null as Promise<unknown> | null }; 20 + 21 + interface UseInboxResult { 22 + /** Current inbox entries. Empty array before bootstrap completes. */ 23 + readonly grants: readonly InboxGrant[]; 24 + /** 25 + * `true` until the keeper has been bootstrapped at least once. 26 + * After bootstrap, `grants` is authoritative even when empty. 27 + */ 28 + readonly isLoading: boolean; 29 + } 30 + 31 + export function useInbox(): UseInboxResult { 32 + const [snapshot, setSnapshot] = useState<InboxSnapshot | null>(null); 33 + 34 + useEffect(() => { 35 + const watcher = getOpake().watchInbox((snap) => { 36 + setSnapshot(snap); 37 + 38 + // Bootstrap exactly once: only needed when the keeper hasn't loaded yet 39 + // and no bootstrap is already in flight. Once `snap.loaded` flips to 40 + // true (after the first listInbox completes), this guard never fires again. 41 + if (!snap.loaded && !_bootstrap.promise) { 42 + // eslint-disable-next-line functional/immutable-data -- module-level dedup flag for bootstrap 43 + _bootstrap.promise = getOpake() 44 + .listInbox() 45 + .catch((err: unknown) => { 46 + console.warn("[shared] listInbox bootstrap failed:", err); 47 + }) 48 + .finally(() => { 49 + // eslint-disable-next-line functional/immutable-data -- clear after bootstrap 50 + _bootstrap.promise = null; 51 + }); 52 + } 53 + }); 54 + 55 + return () => watcher.close(); 56 + }, []); 57 + 58 + return { 59 + grants: snapshot?.entries ?? [], 60 + isLoading: !snapshot?.loaded, 61 + }; 62 + }
+26 -19
apps/web/src/routes/cabinet/shared.lazy.tsx
··· 6 6 import { getOpake } from "@/stores/auth"; 7 7 import { toastError, toastSuccess } from "@/stores/toast"; 8 8 import { triggerBrowserDownload } from "@/lib/download"; 9 + import { useInbox } from "@/hooks/use-inbox"; 9 10 10 11 const METADATA_BATCH_SIZE = 5; 11 12 ··· 18 19 } 19 20 20 21 function SharedWithMePage() { 21 - const [grants, setGrants] = useState<readonly InboxGrant[] | null>(null); 22 + const { grants, isLoading } = useInbox(); 22 23 const [metadataByUri, setMetadataByUri] = useState< 23 24 Readonly<Partial<Record<string, ResolvedGrantMetadata>>> 24 25 >({}); ··· 31 32 // render when only `metadataByUri` updates. 32 33 const resolutionKickedRef = useRef(new Set<string>()); 33 34 34 - useEffect(() => { 35 - const watcher = getOpake().watchInbox((snap) => { 36 - setGrants(snap.loaded ? snap.entries : null); 37 - }); 38 - void getOpake() 39 - .listInbox() 40 - .catch((err: unknown) => { 41 - console.warn("[shared] listInbox failed:", err); 42 - }); 43 - return () => watcher.close(); 44 - }, []); 45 - 46 35 // Resolve metadata + owner handles for new grants in bounded batches. 47 36 // Batches run serially (first 5, then next 5, etc.) to avoid opening 50 48 37 // cross-PDS fetches simultaneously on a heavy inbox. ··· 50 39 // Failures are NOT permanently kicked — the retry button clears failedByUri 51 40 // for a URI and removes it from resolutionKickedRef so the effect picks it up 52 41 // again on the next render. 42 + // 43 + // Each effect invocation gets its own `cancelled` flag (same pattern as 44 + // settings.lazy.tsx). The cleanup marks it true so stale async batches from 45 + // a previous invocation drop their setState calls instead of racing with the 46 + // fresh run. 53 47 useEffect(() => { 54 - if (!grants) return; 48 + if (isLoading) return; 55 49 56 50 const pending = grants.filter( 57 51 (g) => !resolutionKickedRef.current.has(g.uri) && metadataByUri[g.uri] === undefined, ··· 68 62 (_, i) => pending.slice(i * METADATA_BATCH_SIZE, (i + 1) * METADATA_BATCH_SIZE), 69 63 ); 70 64 65 + // Local cancellation flag: true once this effect instance is superseded or 66 + // the component unmounts. Declared inside the effect so the cleanup below 67 + // can set it without triggering functional/immutable-data on an outer ref. 68 + const cancelled = { current: false }; 69 + // Wrapped in a thunk so TypeScript doesn't narrow `cancelled.current` to 70 + // `false` after the first check and flag subsequent checks as always-falsy. 71 + const isCancelled = (): boolean => cancelled.current; 72 + 71 73 void batches.reduce(async (prev, batch) => { 72 74 await prev; 75 + if (isCancelled()) return; 73 76 74 77 const metaResults = await Promise.allSettled( 75 78 batch.map((g) => getOpake().resolveGrantMetadata(g.uri)), ··· 78 81 const ownerResults = await Promise.allSettled( 79 82 ownerDids.map((did) => getOpake().resolveIdentity(did)), 80 83 ); 84 + 85 + if (isCancelled()) return; 81 86 82 87 setHandleByDid((prev) => ({ 83 88 ...prev, ··· 119 124 ...Object.fromEntries(failureEntries), 120 125 })); 121 126 }, Promise.resolve()); 122 - }, [grants, metadataByUri, failedByUri, handleByDid]); 127 + 128 + return () => { 129 + cancelled.current = true; 130 + }; 131 + }, [grants, isLoading, metadataByUri, failedByUri, handleByDid]); 123 132 124 133 const retryResolution = useCallback((uri: string) => { 125 134 resolutionKickedRef.current.delete(uri); ··· 129 138 }, []); 130 139 131 140 const entries: readonly ResolvedEntry[] = useMemo(() => { 132 - if (!grants) return []; 141 + if (isLoading) return []; 133 142 return grants.map((grant) => { 134 143 const metadata = metadataByUri[grant.uri] ?? null; 135 144 const ownerHandle = handleByDid[grant.ownerDid] ?? null; ··· 138 147 if (err) return { grant, metadata: null, ownerHandle, status: "error" as const, error: err }; 139 148 return { grant, metadata: null, ownerHandle, status: "resolving" as const }; 140 149 }); 141 - }, [grants, metadataByUri, failedByUri, handleByDid]); 150 + }, [grants, isLoading, metadataByUri, failedByUri, handleByDid]); 142 151 143 152 const handleDownload = useCallback(async (grantUri: string) => { 144 153 setDownloading(grantUri); ··· 163 172 </ul> 164 173 </div> 165 174 ); 166 - 167 - const isLoading = grants === null; 168 175 169 176 return ( 170 177 <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Incoming shares">
+33 -4
crates/opake-core/src/sharing/pending.rs
··· 5 5 // retries periodically. On success, a grant is created and the pending 6 6 // record is deleted. 7 7 8 - use std::collections::HashMap; 8 + use std::collections::{HashMap, HashSet}; 9 9 10 10 use log::{info, trace, warn}; 11 11 ··· 150 150 // crypto unwrap when multiple pending shares reference the same document. 151 151 let mut content_key_cache: HashMap<String, ContentKey> = HashMap::new(); 152 152 153 + // Track document URIs where the content-key fetch failed with a permanent 154 + // error (document deleted, record corrupted, or decryption failure). 155 + // Unlike transient errors, these won't heal on retry — skip subsequent 156 + // pending shares that reference the same broken document in this pass. 157 + let mut permanent_document_errors: HashSet<String> = HashSet::new(); 158 + 153 159 for entry in &entries { 154 160 let at_uri = match atproto::parse_at_uri(&entry.uri) { 155 161 Ok(u) => u, ··· 222 228 entry.uri, entry.recipient 223 229 ); 224 230 225 - // Fetch content key (cached per document) 231 + // Fetch content key (cached per document). 232 + // Skip immediately for documents that already failed with a permanent 233 + // error earlier in this pass — no point hammering the PDS again. 234 + if permanent_document_errors.contains(&entry.document) { 235 + result.failed += 1; 236 + continue; 237 + } 238 + 226 239 let content_key = match content_key_cache.get(&entry.document) { 227 240 Some(key) => key.clone(), 228 241 None => { ··· 239 252 key 240 253 } 241 254 Err(e) => { 255 + // Permanent failures: document was deleted, the record is 256 + // corrupt, or the content-key ciphertext won't unwrap. 257 + // Mark the document URI so sibling pending shares for the 258 + // same document skip the round-trip. 259 + let permanent = matches!( 260 + e, 261 + Error::NotFound(_) 262 + | Error::InvalidRecord(_) 263 + | Error::Decryption(_) 264 + | Error::Serialization(_) 265 + ); 242 266 warn!( 243 - "pending share {}: failed to fetch content key for {}: {e}", 244 - entry.uri, entry.document 267 + "pending share {}: failed to fetch content key for {} ({}): {e}", 268 + entry.uri, 269 + entry.document, 270 + if permanent { "permanent" } else { "transient" }, 245 271 ); 272 + if permanent { 273 + permanent_document_errors.insert(entry.document.clone()); 274 + } 246 275 result.failed += 1; 247 276 continue; 248 277 }