this repo has no description
1
fork

Configure Feed

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

Rewrite sharing page with cabinet components, incoming grant decryption, and preview

Unify the sharing page with the cabinet file browser — reuse FileListRow,
FileGridCard, list/grid toggle, and split-panel preview. Add incoming grant
resolution: fetch grant + document records from owner's PDS, unwrap content
key, decrypt document metadata to show real file names. Add download for
incoming shared files. Export DID resolution helpers (didDocumentUrl,
handleFromDidDocument, pdsFromDidDocument) from opake-wasm for shared
DID-to-handle and DID-to-PDS resolution in the web client. Refactor
FilePreview to accept a decrypt function instead of hardcoding the
document source, eliminating the need for a separate SharedFilePreview.

+1002 -229
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add web sharing UI with grant management [#149](https://issues.opake.app/issues/149.html) 15 16 - Add user banner to profile dropdown and cache profile images in IndexedDB [#288](https://issues.opake.app/issues/288.html) 16 17 - Add split-panel preview, UI cleanup, and fix document name flicker [#286](https://issues.opake.app/issues/286.html) 17 18 - Add markdown renderer for document preview [#206](https://issues.opake.app/issues/206.html)
+1
Cargo.lock
··· 1614 1614 "opake-core", 1615 1615 "serde", 1616 1616 "serde-wasm-bindgen", 1617 + "serde_json", 1617 1618 "wasm-bindgen", 1618 1619 ] 1619 1620
+24 -9
crates/opake-core/src/client/did.rs
··· 163 163 transport: &impl Transport, 164 164 did: &str, 165 165 ) -> Result<DidDocument, Error> { 166 - let url = if did.starts_with("did:plc:") { 167 - format!("{PLC_DIRECTORY}/{did}") 168 - } else if let Some(domain) = did.strip_prefix("did:web:") { 169 - format!("https://{domain}/.well-known/did.json") 170 - } else { 171 - return Err(Error::InvalidRecord(format!( 172 - "unsupported DID method: {did}" 173 - ))); 174 - }; 166 + let url = did_document_url(did)?; 175 167 176 168 debug!("fetching DID document from {}", url); 177 169 ··· 186 178 187 179 check_response(&response)?; 188 180 Ok(serde_json::from_slice(&response.body)?) 181 + } 182 + 183 + /// Build the URL to fetch a DID document (PLC directory or did:web .well-known). 184 + pub fn did_document_url(did: &str) -> Result<String, Error> { 185 + if did.starts_with("did:plc:") { 186 + Ok(format!("{PLC_DIRECTORY}/{did}")) 187 + } else if let Some(domain) = did.strip_prefix("did:web:") { 188 + Ok(format!("https://{domain}/.well-known/did.json")) 189 + } else { 190 + Err(Error::InvalidRecord(format!( 191 + "unsupported DID method: {did}" 192 + ))) 193 + } 194 + } 195 + 196 + /// Extract the handle from a DID document's `alsoKnownAs` field. 197 + /// 198 + /// Returns the first entry starting with `at://`, stripped of the prefix. 199 + pub fn handle_from_did_document(doc: &DidDocument) -> Option<String> { 200 + doc.also_known_as 201 + .iter() 202 + .find(|a| a.starts_with("at://")) 203 + .map(|a| a[5..].to_string()) 189 204 } 190 205 191 206 /// Extract the PDS service endpoint (`#atproto_pds`) from a DID document.
+48
crates/opake-core/src/client/did_tests.rs
··· 273 273 }; 274 274 assert!(pds_from_did_document(&doc).is_err()); 275 275 } 276 + 277 + // -- did_document_url -- 278 + 279 + #[test] 280 + fn did_document_url_plc() { 281 + let url = did_document_url("did:plc:abc123").unwrap(); 282 + assert_eq!(url, "https://plc.directory/did:plc:abc123"); 283 + } 284 + 285 + #[test] 286 + fn did_document_url_web() { 287 + let url = did_document_url("did:web:example.com").unwrap(); 288 + assert_eq!(url, "https://example.com/.well-known/did.json"); 289 + } 290 + 291 + #[test] 292 + fn did_document_url_unsupported() { 293 + let err = did_document_url("did:key:z123").unwrap_err(); 294 + assert!(err.to_string().contains("unsupported DID method")); 295 + } 296 + 297 + // -- handle_from_did_document -- 298 + 299 + #[test] 300 + fn handle_from_did_document_extracts_handle() { 301 + let doc: DidDocument = serde_json::from_str(&plc_document_json()).unwrap(); 302 + assert_eq!(handle_from_did_document(&doc), Some("alice.test".into())); 303 + } 304 + 305 + #[test] 306 + fn handle_from_did_document_no_at_entry() { 307 + let doc = DidDocument { 308 + id: "did:plc:test".into(), 309 + also_known_as: vec!["https://example.com".into()], 310 + service: vec![], 311 + }; 312 + assert_eq!(handle_from_did_document(&doc), None); 313 + } 314 + 315 + #[test] 316 + fn handle_from_did_document_empty() { 317 + let doc = DidDocument { 318 + id: "did:plc:test".into(), 319 + also_known_as: vec![], 320 + service: vec![], 321 + }; 322 + assert_eq!(handle_from_did_document(&doc), None); 323 + }
+1
crates/opake-wasm/Cargo.toml
··· 13 13 wasm-bindgen = "0.2" 14 14 serde-wasm-bindgen = "0.6" 15 15 serde = { workspace = true } 16 + serde_json = { workspace = true } 16 17 log = { workspace = true } 17 18 console_log = { version = "1", features = ["color"] } 18 19 console_error_panic_hook = "0.1"
+26
crates/opake-wasm/src/lib.rs
··· 404 404 } 405 405 406 406 // --------------------------------------------------------------------------- 407 + // DID document utilities 408 + // --------------------------------------------------------------------------- 409 + 410 + /// Return the URL to fetch a DID document (PLC directory or did:web .well-known). 411 + #[wasm_bindgen(js_name = didDocumentUrl)] 412 + pub fn did_document_url_js(did: &str) -> Result<String, JsError> { 413 + opake_core::client::did_document_url(did).map_err(|e| JsError::new(&e.to_string())) 414 + } 415 + 416 + /// Parse a fetched DID document and extract the handle from `alsoKnownAs`. 417 + #[wasm_bindgen(js_name = handleFromDidDocument)] 418 + pub fn handle_from_did_document_js(doc_json: &[u8]) -> Result<Option<String>, JsError> { 419 + let doc: opake_core::client::DidDocument = serde_json::from_slice(doc_json) 420 + .map_err(|e: serde_json::Error| JsError::new(&e.to_string()))?; 421 + Ok(opake_core::client::handle_from_did_document(&doc)) 422 + } 423 + 424 + /// Parse a fetched DID document and extract the PDS service endpoint. 425 + #[wasm_bindgen(js_name = pdsFromDidDocument)] 426 + pub fn pds_from_did_document_js(doc_json: &[u8]) -> Result<String, JsError> { 427 + let doc: opake_core::client::DidDocument = serde_json::from_slice(doc_json) 428 + .map_err(|e: serde_json::Error| JsError::new(&e.to_string()))?; 429 + opake_core::client::pds_from_did_document(&doc).map_err(|e| JsError::new(&e.to_string())) 430 + } 431 + 432 + // --------------------------------------------------------------------------- 407 433 // DirectoryTree handle (stateful WASM export) 408 434 // --------------------------------------------------------------------------- 409 435
+27 -14
web/src/components/cabinet/FileGridCard.tsx
··· 1 + import type { ReactNode } from "react"; 1 2 import { LockIcon } from "@phosphor-icons/react"; 2 3 import { FileActionMenu } from "./FileActionMenu"; 3 4 import { StatusBadge } from "./StatusBadge"; ··· 8 9 readonly item: FileItem; 9 10 readonly isActive?: boolean; 10 11 readonly onClick: () => void; 12 + readonly renderActions?: () => ReactNode; 13 + readonly hideStatus?: boolean; 11 14 readonly onPreview?: () => void; 12 15 readonly onEditMetadata?: () => void; 13 16 readonly onRename?: () => void; ··· 18 21 readonly onDeleteFolder?: () => void; 19 22 } 20 23 24 + // eslint-disable-next-line sonarjs/cognitive-complexity -- flat component with conditional prop rendering 21 25 export function FileGridCard({ 22 26 item, 23 27 isActive, 24 28 onClick, 29 + renderActions, 30 + hideStatus, 25 31 onPreview, 26 32 onEditMetadata, 27 33 onRename, ··· 68 74 {fileIconElement(item, 17)} 69 75 </div> 70 76 <div className="flex items-center gap-1"> 71 - <FileActionMenu 72 - item={item} 73 - onPreview={onPreview} 74 - onEditMetadata={onEditMetadata} 75 - onRename={onRename} 76 - onMove={onMove} 77 - onShare={onShare} 78 - onDownload={onDownload} 79 - onDelete={onDelete} 80 - onDeleteFolder={onDeleteFolder} 81 - /> 77 + {renderActions ? ( 78 + renderActions() 79 + ) : ( 80 + <FileActionMenu 81 + item={item} 82 + onPreview={onPreview} 83 + onEditMetadata={onEditMetadata} 84 + onRename={onRename} 85 + onMove={onMove} 86 + onShare={onShare} 87 + onDownload={onDownload} 88 + onDelete={onDelete} 89 + onDeleteFolder={onDeleteFolder} 90 + /> 91 + )} 82 92 <LockIcon size={11} className="text-text-faint" /> 83 93 </div> 84 94 </div> ··· 90 100 </div> 91 101 92 102 {item.decrypted ? ( 93 - <div className="text-base-content mb-1.5 truncate text-xs">{item.name}</div> 103 + <div className="text-base-content mb-0.5 truncate text-xs">{item.name}</div> 94 104 ) : ( 95 - <div className="skeleton mb-1.5 h-4 w-24 rounded" /> 105 + <div className="skeleton mb-0.5 h-4 w-24 rounded" /> 106 + )} 107 + {item.subtitle && ( 108 + <div className="text-caption text-text-faint mb-1 truncate">{item.subtitle}</div> 96 109 )} 97 110 <div className="flex items-center justify-between"> 98 111 <span className="text-caption text-text-faint">{item.modified}</span> 99 - <StatusBadge status={item.status} /> 112 + {!hideStatus && <StatusBadge status={item.status} />} 100 113 </div> 101 114 </div> 102 115 );
+29 -14
web/src/components/cabinet/FileListRow.tsx
··· 1 + import type { ReactNode } from "react"; 1 2 import { CaretRightIcon } from "@phosphor-icons/react"; 2 3 import { FileActionMenu } from "./FileActionMenu"; 3 4 import { StatusBadge } from "./StatusBadge"; ··· 8 9 readonly item: FileItem; 9 10 readonly isActive?: boolean; 10 11 readonly onClick: () => void; 12 + readonly renderActions?: () => ReactNode; 13 + readonly hideStatus?: boolean; 11 14 readonly onPreview?: () => void; 12 15 readonly onEditMetadata?: () => void; 13 16 readonly onRename?: () => void; ··· 18 21 readonly onDeleteFolder?: () => void; 19 22 } 20 23 24 + // eslint-disable-next-line sonarjs/cognitive-complexity -- flat component with conditional prop rendering 21 25 export function FileListRow({ 22 26 item, 23 27 isActive, 24 28 onClick, 29 + renderActions, 30 + hideStatus, 25 31 onPreview, 26 32 onEditMetadata, 27 33 onRename, ··· 65 71 > 66 72 {/* Actions */} 67 73 <div className="w-6"> 68 - <FileActionMenu 69 - item={item} 70 - onPreview={onPreview} 71 - onEditMetadata={onEditMetadata} 72 - onRename={onRename} 73 - onMove={onMove} 74 - onShare={onShare} 75 - onDownload={onDownload} 76 - onDelete={onDelete} 77 - onDeleteFolder={onDeleteFolder} 78 - /> 74 + {renderActions ? ( 75 + renderActions() 76 + ) : ( 77 + <FileActionMenu 78 + item={item} 79 + onPreview={onPreview} 80 + onEditMetadata={onEditMetadata} 81 + onRename={onRename} 82 + onMove={onMove} 83 + onShare={onShare} 84 + onDownload={onDownload} 85 + onDelete={onDelete} 86 + onDeleteFolder={onDeleteFolder} 87 + /> 88 + )} 79 89 </div> 80 90 81 91 {/* Icon */} ··· 92 102 </div> 93 103 ) : ( 94 104 <div className="skeleton h-4 w-36 rounded" /> 105 + )} 106 + {item.subtitle && ( 107 + <div className="text-caption text-text-faint truncate">{item.subtitle}</div> 95 108 )} 96 109 <div className="text-caption text-text-faint mt-0.5 flex items-center gap-1.5"> 97 110 <span>{item.modified}</span> ··· 125 138 )} 126 139 127 140 {/* Status */} 128 - <div className="flex shrink-0 items-center gap-2"> 129 - <StatusBadge status={item.status} /> 130 - </div> 141 + {!hideStatus && ( 142 + <div className="flex shrink-0 items-center gap-2"> 143 + <StatusBadge status={item.status} /> 144 + </div> 145 + )} 131 146 </div> 132 147 ); 133 148 }
+23 -52
web/src/components/cabinet/FilePreview.tsx
··· 1 - // Preview container — handles blob decryption and dispatches to the 2 - // appropriate renderer based on MIME type. 1 + // Preview container — caches a decrypt promise (Suspense-stable) and 2 + // dispatches to the appropriate renderer based on MIME type. 3 + // The caller provides the decrypt function — FilePreview doesn't care 4 + // whether the blob came from the user's PDS or a shared grant. 3 5 4 6 import { use } from "react"; 5 7 import { DownloadSimpleIcon } from "@phosphor-icons/react"; 6 8 import { ImagePreview } from "./ImagePreview"; 7 9 import { MarkdownPreview } from "./MarkdownPreview"; 8 - import { decryptDocumentBlob, type DecryptedBlob } from "@/lib/preview"; 9 - import { useDocumentsStore } from "@/stores/documents"; 10 - import { useAuthStore } from "@/stores/auth"; 11 - import { base64ToUint8Array } from "@/lib/encoding"; 12 - import type { PdsRecord, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 13 - import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 14 - 15 - const storage = new IndexedDbStorage(); 10 + import type { DecryptedBlob } from "@/lib/preview"; 16 11 17 12 interface FilePreviewProps { 18 - readonly documentUri: string; 13 + readonly cacheKey: string; 14 + readonly decrypt: () => Promise<DecryptedBlob>; 15 + readonly onDownload: () => void; 19 16 } 20 17 21 18 function isImageMime(mime: string): boolean { ··· 32 29 | { readonly status: "unsupported"; readonly mimeType: string }; 33 30 34 31 // Module-level promise cache — survives Suspense unmount/remount cycles. 35 - // Keyed by documentUri so each document decrypts exactly once. 36 32 const decryptCache = new Map<string, Promise<DecryptResult>>(); 37 33 38 - function getOrCreateDecryptPromise(documentUri: string): Promise<DecryptResult> { 39 - const cached = decryptCache.get(documentUri); 34 + function getOrCreate( 35 + cacheKey: string, 36 + decrypt: () => Promise<DecryptedBlob>, 37 + ): Promise<DecryptResult> { 38 + const cached = decryptCache.get(cacheKey); 40 39 if (cached) return cached; 41 40 42 - const promise = fetchAndDecrypt(documentUri); 41 + const promise = run(decrypt); 43 42 // eslint-disable-next-line functional/immutable-data -- module-level cache for Suspense stability 44 - decryptCache.set(documentUri, promise); 43 + decryptCache.set(cacheKey, promise); 45 44 return promise; 46 45 } 47 46 48 47 /** Call when navigating away from a preview to free the cached result. */ 49 - export function evictPreviewCache(documentUri: string): void { 48 + export function evictPreviewCache(cacheKey: string): void { 50 49 // eslint-disable-next-line functional/immutable-data -- module-level cache cleanup 51 - decryptCache.delete(documentUri); 50 + decryptCache.delete(cacheKey); 52 51 } 53 52 54 - async function fetchAndDecrypt(documentUri: string): Promise<DecryptResult> { 53 + async function run(decrypt: () => Promise<DecryptedBlob>): Promise<DecryptResult> { 55 54 try { 56 - const state = useDocumentsStore.getState(); 57 - const record = state.documentRecords[documentUri] as PdsRecord<DocumentRecord> | undefined; 58 - if (!record) { 59 - return { status: "error", message: "Document record not found" }; 60 - } 61 - 62 - const authState = useAuthStore.getState(); 63 - if (authState.session.status !== "active") { 64 - return { status: "error", message: "Not authenticated" }; 65 - } 66 - 67 - const { did, pdsUrl } = authState.session; 68 - const session = await storage.loadSession(did); 69 - const identity = await storage.loadIdentity(did); 70 - const privateKey = base64ToUint8Array(identity.private_key); 71 - 72 - const storeItem = state.items[documentUri]; 73 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 74 - const knownMetadata: DocumentMetadata | undefined = storeItem?.decrypted 75 - ? { 76 - name: storeItem.name, 77 - mimeType: storeItem.mimeType, 78 - tags: storeItem.tags, 79 - description: storeItem.description, 80 - } 81 - : undefined; 82 - 83 - const blob = await decryptDocumentBlob(record, pdsUrl, did, privateKey, session, knownMetadata); 55 + const blob = await decrypt(); 84 56 const mime = blob.metadata.mimeType ?? "application/octet-stream"; 85 57 const filename = blob.metadata.name; 86 58 ··· 96 68 } 97 69 } 98 70 99 - export function FilePreview({ documentUri }: FilePreviewProps) { 100 - const downloadFile = useDocumentsStore((s) => s.downloadFile); 101 - const result = use(getOrCreateDecryptPromise(documentUri)); 71 + export function FilePreview({ cacheKey, decrypt, onDownload }: FilePreviewProps) { 72 + const result = use(getOrCreate(cacheKey, decrypt)); 102 73 103 74 if (result.status === "error") { 104 75 return ( 105 76 <div className="flex h-full flex-col items-center justify-center gap-3 p-8"> 106 77 <span className="text-ui text-text-muted">{result.message}</span> 107 - <DownloadButton onClick={() => void downloadFile(documentUri)} /> 78 + <DownloadButton onClick={onDownload} /> 108 79 </div> 109 80 ); 110 81 } ··· 113 84 return ( 114 85 <div className="flex h-full flex-col items-center justify-center gap-3 p-8"> 115 86 <span className="text-ui text-text-muted">Preview not available for {result.mimeType}</span> 116 - <DownloadButton onClick={() => void downloadFile(documentUri)} /> 87 + <DownloadButton onClick={onDownload} /> 117 88 </div> 118 89 ); 119 90 }
+31
web/src/components/cabinet/RevokeShareDialog.tsx
··· 1 + import { forwardRef } from "react"; 2 + import { ProhibitIcon } from "@phosphor-icons/react"; 3 + import { ConfirmDialog, type ConfirmDialogHandle } from "@/components/ConfirmDialog"; 4 + 5 + interface RevokeShareDialogProps { 6 + readonly onConfirm: (grantUri: string) => void; 7 + } 8 + 9 + export const RevokeShareDialog = forwardRef<ConfirmDialogHandle, RevokeShareDialogProps>( 10 + function RevokeShareDialog({ onConfirm }, ref) { 11 + return ( 12 + <ConfirmDialog 13 + ref={ref} 14 + title="Stop sharing?" 15 + icon={ProhibitIcon} 16 + iconClassName="text-warning" 17 + iconBgClassName="bg-warning/10" 18 + confirmLabel="Stop sharing" 19 + confirmClassName="btn btn-warning btn-sm rounded-lg text-xs" 20 + onConfirm={onConfirm} 21 + > 22 + {(fileName) => ( 23 + <p> 24 + <span className="text-base-content font-medium">{fileName}</span> will no longer be 25 + accessible to the recipient. 26 + </p> 27 + )} 28 + </ConfirmDialog> 29 + ); 30 + }, 31 + );
+12 -1
web/src/components/cabinet/ShareDialog.tsx
··· 7 7 import { base64ToUint8Array } from "@/lib/encoding"; 8 8 import { authenticatedXrpc } from "@/lib/api"; 9 9 import { toastSuccess, toastError } from "@/stores/toast"; 10 + import { useDocumentsStore } from "@/stores/documents"; 10 11 import type { DocumentRecord, Encryption } from "@/lib/pdsTypes"; 11 12 import type { OAuthSession } from "@/lib/storageTypes"; 12 13 import { MODAL_TRANSITION_MS } from "@/components/ConfirmDialog"; ··· 122 123 session: oauthSession, 123 124 }); 124 125 126 + // Optimistically mark the item as shared in the store 127 + const { items } = useDocumentsStore.getState(); 128 + const item = items[documentUri]; 129 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 130 + if (item) { 131 + useDocumentsStore.setState((state) => ({ 132 + items: { ...state.items, [documentUri]: { ...item, status: "shared" as const } }, 133 + })); 134 + } 135 + 125 136 setStatus("done"); 126 137 toastSuccess(`Shared "${documentName}" with ${handle}`); 127 138 dismiss(); ··· 159 170 type="text" 160 171 placeholder="alice.bsky.social" 161 172 value={recipientHandle} 162 - onChange={(e) => setRecipientHandle(e.target.value)} 173 + onChange={(e) => setRecipientHandle(e.target.value.replace(/[^a-zA-Z0-9.:_-]/g, ""))} 163 174 onKeyDown={(e) => { 164 175 if (e.key === "Enter" && !busy && recipientHandle.trim()) { 165 176 void handleShare();
+3 -3
web/src/components/cabinet/Sidebar.tsx
··· 6 6 7 7 const MAIN_NAV = [ 8 8 { to: "/cabinet/files" as const, icon: FolderIcon, label: "Your Cabinet" }, 9 - { to: "/cabinet/shared" as const, icon: UsersIcon, label: "Shared with me", badge: "4" }, 9 + { to: "/cabinet/shared" as const, icon: UsersIcon, label: "Sharing" }, 10 10 ]; 11 11 12 12 const BOTTOM_NAV = [ ··· 33 33 34 34 {/* Main nav */} 35 35 <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto"> 36 - {MAIN_NAV.map(({ to, icon, label, badge }) => ( 37 - <SidebarItem key={to} to={to} icon={icon} label={label} badge={badge} /> 36 + {MAIN_NAV.map(({ to, icon, label }) => ( 37 + <SidebarItem key={to} to={to} icon={icon} label={label} /> 38 38 ))} 39 39 40 40 {/* Workspaces */}
+2 -2
web/src/components/cabinet/TopBar.tsx
··· 118 118 className="bg-base-300/60 absolute inset-0 bg-cover bg-center" 119 119 style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined} 120 120 /> 121 - <div className="from-base-100 absolute inset-0 bg-gradient-to-t to-transparent" /> 121 + <div className="from-base-100 absolute inset-0 bg-linear-to-t to-transparent" /> 122 122 </div> 123 - <div className="border-base-300/50 border-b px-3.5 pb-2.5 pt-2"> 123 + <div className="border-base-300/50 border-b px-3.5 pt-2 pb-2.5"> 124 124 <div className="text-ui text-base-content font-medium">{handle}</div> 125 125 <div className="text-caption text-text-faint mt-0.5">{truncateDid(did)}</div> 126 126 </div>
+1
web/src/components/cabinet/types.ts
··· 17 17 tags: string[]; 18 18 mimeType?: string; 19 19 description?: string; 20 + subtitle?: string; 20 21 } 21 22 22 23 const PREVIEWABLE_FILE_TYPES: ReadonlySet<FileType> = new Set(["image", "note"]);
+32
web/src/lib/did.ts
··· 1 + // DID document resolution — JS fetch + WASM parsing. 2 + // 3 + // URL construction and document parsing are delegated to opake-core via WASM. 4 + // This module handles the HTTP fetch (which WASM can't do). 5 + 6 + import { getCryptoWorker } from "@/lib/worker"; 7 + 8 + /** Fetch and parse a DID document, returning the PDS URL. */ 9 + export async function pdsUrlFromDid(did: string): Promise<string> { 10 + const worker = getCryptoWorker(); 11 + const url = await worker.didDocumentUrl(did); 12 + 13 + const response = await fetch(url); 14 + if (!response.ok) { 15 + throw new Error(`Failed to fetch DID document for ${did}: HTTP ${response.status}`); 16 + } 17 + 18 + const docBytes = new Uint8Array(await response.arrayBuffer()); 19 + return worker.pdsFromDidDocument(docBytes); 20 + } 21 + 22 + /** Fetch a DID document and extract the handle from `alsoKnownAs`. */ 23 + export async function handleFromDid(did: string): Promise<string | undefined> { 24 + const worker = getCryptoWorker(); 25 + const url = await worker.didDocumentUrl(did); 26 + 27 + const response = await fetch(url); 28 + if (!response.ok) return undefined; 29 + 30 + const docBytes = new Uint8Array(await response.arrayBuffer()); 31 + return worker.handleFromDidDocument(docBytes); 32 + }
+1 -1
web/src/lib/download.ts
··· 25 25 triggerBrowserDownload(plaintext, filename, mimeType); 26 26 } 27 27 28 - function triggerBrowserDownload(data: Uint8Array, filename: string, mimeType: string): void { 28 + export function triggerBrowserDownload(data: Uint8Array, filename: string, mimeType: string): void { 29 29 const buffer = new ArrayBuffer(data.byteLength); 30 30 new Uint8Array(buffer).set(data); 31 31 const blob = new Blob([buffer], { type: mimeType });
+4 -25
web/src/lib/oauth.ts
··· 6 6 import type { Remote } from "comlink"; 7 7 import type { CryptoApi } from "@/workers/crypto.worker"; 8 8 import type { DpopKeyPair } from "@/lib/cryptoTypes"; 9 + import { pdsUrlFromDid } from "@/lib/did"; 9 10 10 11 type CryptoWorker = Remote<CryptoApi>; 11 12 ··· 58 59 const resolveUrl = `${BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 59 60 const response = await fetch(resolveUrl); 60 61 if (!response.ok) { 61 - throw new Error(`Failed to resolve handle "${handle}": HTTP ${response.status}`); 62 + throw new Error( 63 + response.status === 400 ? "Handle not found" : `Failed to resolve handle "${handle}"`, 64 + ); 62 65 } 63 66 const { did } = (await response.json()) as { did: string }; 64 67 65 68 const pdsUrl = await pdsUrlFromDid(did); 66 69 return { did, pdsUrl }; 67 - } 68 - 69 - async function pdsUrlFromDid(did: string): Promise<string> { 70 - const docUrl = did.startsWith("did:plc:") 71 - ? `https://plc.directory/${did}` 72 - : did.startsWith("did:web:") 73 - ? `https://${did.slice("did:web:".length)}/.well-known/did.json` 74 - : null; 75 - 76 - if (!docUrl) throw new Error(`Unsupported DID method: ${did}`); 77 - 78 - const response = await fetch(docUrl); 79 - if (!response.ok) { 80 - throw new Error(`Failed to fetch DID document for ${did}: HTTP ${response.status}`); 81 - } 82 - 83 - const doc = (await response.json()) as { 84 - service?: { id: string; serviceEndpoint: string }[]; 85 - }; 86 - 87 - const pds = doc.service?.find((s) => s.id === "#atproto_pds"); 88 - if (!pds) throw new Error(`No #atproto_pds service in DID document for ${did}`); 89 - 90 - return pds.serviceEndpoint; 91 70 } 92 71 93 72 // ---------------------------------------------------------------------------
+39
web/src/lib/preview.ts
··· 4 4 import { base64ToUint8Array } from "@/lib/encoding"; 5 5 import { getCryptoWorker } from "@/lib/worker"; 6 6 import { unwrapDirectContentKey, decryptEnvelope } from "@/stores/documents/decrypt"; 7 + import { useDocumentsStore } from "@/stores/documents"; 8 + import { useAuthStore } from "@/stores/auth"; 9 + import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 7 10 import type { PdsRecord, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 8 11 import type { Session } from "@/lib/storageTypes"; 12 + 13 + const previewStorage = new IndexedDbStorage(); 9 14 10 15 export interface DecryptedBlob { 11 16 readonly plaintext: Uint8Array; ··· 57 62 58 63 return { plaintext, metadata }; 59 64 } 65 + 66 + /** 67 + * Create a decrypt function for a document owned by the current user. 68 + * Pulls the record from the documents store, session + identity from IndexedDB. 69 + * Suitable for passing directly to `<FilePreview decrypt={...} />`. 70 + */ 71 + export function decryptOwnDocument(documentUri: string): () => Promise<DecryptedBlob> { 72 + return async () => { 73 + const state = useDocumentsStore.getState(); 74 + const record = state.documentRecords[documentUri] as PdsRecord<DocumentRecord> | undefined; 75 + if (!record) throw new Error("Document record not found"); 76 + 77 + const authState = useAuthStore.getState(); 78 + if (authState.session.status !== "active") throw new Error("Not authenticated"); 79 + 80 + const { did, pdsUrl } = authState.session; 81 + const session = await previewStorage.loadSession(did); 82 + const identity = await previewStorage.loadIdentity(did); 83 + const privateKey = base64ToUint8Array(identity.private_key); 84 + 85 + const storeItem = state.items[documentUri]; 86 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 87 + const knownMetadata: DocumentMetadata | undefined = storeItem?.decrypted 88 + ? { 89 + name: storeItem.name, 90 + mimeType: storeItem.mimeType, 91 + tags: storeItem.tags, 92 + description: storeItem.description, 93 + } 94 + : undefined; 95 + 96 + return decryptDocumentBlob(record, pdsUrl, did, privateKey, session, knownMetadata); 97 + }; 98 + }
+126 -7
web/src/lib/sharing.ts
··· 1 1 // Sharing helpers — resolve recipient, create/list/revoke grants. 2 2 3 3 import type { WrappedKey } from "@/lib/cryptoTypes"; 4 - import type { EncryptedMetadataEnvelope } from "@/lib/pdsTypes"; 4 + import type { EncryptedMetadataEnvelope, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 5 + import type { DecryptedBlob } from "@/lib/preview"; 5 6 import type { Session } from "@/lib/storageTypes"; 6 7 import { authenticatedXrpc, authenticatedDeleteRecord, appview } from "@/lib/api"; 7 8 import { resolveHandleToPds } from "@/lib/oauth"; 9 + import { pdsUrlFromDid } from "@/lib/did"; 8 10 import { getCryptoWorker } from "@/lib/worker"; 9 11 import { base64ToUint8Array, uint8ArrayToBase64 } from "@/lib/encoding"; 12 + import { rkeyFromUri } from "@/lib/atUri"; 13 + import { triggerBrowserDownload } from "@/lib/download"; 14 + import { decryptEnvelope } from "@/stores/documents/decrypt"; 10 15 import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 11 16 17 + const storage = new IndexedDbStorage(); 12 18 const GRANT_COLLECTION = "app.opake.grant"; 13 19 const PUBLIC_KEY_COLLECTION = "app.opake.publicKey"; 14 20 ··· 58 64 const url = `${base}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(PUBLIC_KEY_COLLECTION)}&rkey=self`; 59 65 const response = await fetch(url); 60 66 if (!response.ok) { 61 - throw new Error(`No Opake public key found for ${handle} (${did})`); 67 + throw new Error(`${handle} hasn't signed in to Opake yet — they need to log in at least once`); 62 68 } 63 69 64 70 const record = (await response.json()) as { ··· 66 72 }; 67 73 const raw = record.value?.publicKey; 68 74 if (!raw) { 69 - throw new Error(`No encryption public key published for ${handle}`); 75 + throw new Error(`${handle} hasn't signed in to Opake yet — they need to log in at least once`); 70 76 } 71 77 72 78 const b64 = typeof raw === "string" ? raw : raw.$bytes; ··· 187 193 188 194 /** Fetch incoming grants from the AppView inbox. */ 189 195 export async function listIncomingGrants(did: string): Promise<InboxGrantItem[]> { 190 - const storage = new IndexedDbStorage(); 191 196 const config = await storage.loadConfig().catch(() => null); 192 197 const appviewUrl = config?.appviewUrl; 193 198 if (!appviewUrl) return []; ··· 224 229 grantUri: string, 225 230 session: Session, 226 231 ): Promise<void> { 227 - const segments = grantUri.split("/"); 228 - const rkey = segments[segments.length - 1]; 232 + await authenticatedDeleteRecord( 233 + { pdsUrl, did, collection: GRANT_COLLECTION, rkey: rkeyFromUri(grantUri) }, 234 + session, 235 + ); 236 + } 237 + 238 + // --------------------------------------------------------------------------- 239 + // Incoming grant resolution (fetch record → unwrap → decrypt metadata) 240 + // --------------------------------------------------------------------------- 241 + 242 + const DOCUMENT_COLLECTION = "app.opake.document"; 243 + 244 + /** Resolved incoming grant with decrypted document metadata. */ 245 + export interface ResolvedIncomingGrant extends InboxGrantItem { 246 + readonly ownerPdsUrl: string; 247 + readonly contentKey: Uint8Array; 248 + readonly documentRecord: DocumentRecord; 249 + readonly metadata: DocumentMetadata; 250 + } 251 + 252 + /** Fetch a record from a PDS without authentication (records are public in atproto). */ 253 + async function publicGetRecord( 254 + pdsUrl: string, 255 + repo: string, 256 + collection: string, 257 + rkey: string, 258 + ): Promise<{ uri: string; cid: string; value: unknown }> { 259 + const params = new URLSearchParams({ repo, collection, rkey }); 260 + const response = await fetch( 261 + `${pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.repo.getRecord?${params}`, 262 + ); 263 + if (!response.ok) throw new Error(`getRecord failed: HTTP ${response.status}`); 264 + return response.json() as Promise<{ uri: string; cid: string; value: unknown }>; 265 + } 266 + 267 + /** 268 + * Resolve an incoming grant: fetch the full grant + document records from the 269 + * owner's PDS, unwrap the content key, and decrypt the document metadata. 270 + */ 271 + export async function resolveIncomingGrant( 272 + grant: InboxGrantItem, 273 + privateKey: Uint8Array, 274 + knownPdsUrl?: string, 275 + ): Promise<ResolvedIncomingGrant> { 276 + const ownerPdsUrl = knownPdsUrl ?? (await pdsUrlFromDid(grant.ownerDid)); 277 + 278 + // Fetch the grant record to get the wrappedKey 279 + const grantResult = await publicGetRecord( 280 + ownerPdsUrl, 281 + grant.ownerDid, 282 + GRANT_COLLECTION, 283 + rkeyFromUri(grant.uri), 284 + ); 285 + const grantRecord = grantResult.value as GrantRecord; 286 + 287 + // Unwrap the content key with our private key 288 + const worker = getCryptoWorker(); 289 + const contentKey = await worker.unwrapKey(grantRecord.wrappedKey, privateKey); 290 + 291 + // Fetch the document record to decrypt its metadata 292 + const docResult = await publicGetRecord( 293 + ownerPdsUrl, 294 + grant.ownerDid, 295 + DOCUMENT_COLLECTION, 296 + rkeyFromUri(grantRecord.document), 297 + ); 298 + const documentRecord = docResult.value as DocumentRecord; 299 + 300 + // Decrypt document metadata using the content key 301 + const { ciphertext, nonce } = decryptEnvelope(documentRecord.encryptedMetadata); 302 + const metadata = await worker.decryptMetadata(contentKey, ciphertext, nonce); 229 303 230 - await authenticatedDeleteRecord({ pdsUrl, did, collection: GRANT_COLLECTION, rkey }, session); 304 + return { 305 + ...grant, 306 + ownerPdsUrl, 307 + contentKey, 308 + documentRecord, 309 + metadata, 310 + }; 311 + } 312 + 313 + /** Fetch and decrypt the blob of a resolved incoming grant. */ 314 + async function decryptIncomingBlob(resolved: ResolvedIncomingGrant): Promise<DecryptedBlob> { 315 + const { encryption } = resolved.documentRecord; 316 + if (encryption.$type !== "app.opake.document#directEncryption") { 317 + throw new Error("Keyring-encrypted documents are not yet supported"); 318 + } 319 + 320 + const cid = resolved.documentRecord.blob.ref.$link; 321 + const blobUrl = `${resolved.ownerPdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(resolved.ownerDid)}&cid=${encodeURIComponent(cid)}`; 322 + const blobResponse = await fetch(blobUrl); 323 + if (!blobResponse.ok) throw new Error(`getBlob failed: HTTP ${blobResponse.status}`); 324 + 325 + const worker = getCryptoWorker(); 326 + const blobNonce = base64ToUint8Array(encryption.envelope.nonce.$bytes); 327 + const plaintext = await worker.decryptBlob( 328 + resolved.contentKey, 329 + new Uint8Array(await blobResponse.arrayBuffer()), 330 + blobNonce, 331 + ); 332 + 333 + return { plaintext, metadata: resolved.metadata }; 334 + } 335 + 336 + /** Download a resolved incoming grant's blob to the user's device. */ 337 + export async function downloadIncomingGrant(resolved: ResolvedIncomingGrant): Promise<void> { 338 + const { plaintext, metadata } = await decryptIncomingBlob(resolved); 339 + triggerBrowserDownload(plaintext, metadata.name, metadata.mimeType ?? "application/octet-stream"); 340 + } 341 + 342 + /** 343 + * Create a decrypt function for a shared incoming document. 344 + * Suitable for passing directly to `<FilePreview decrypt={...} />`. 345 + */ 346 + export function decryptIncomingDocument( 347 + resolved: ResolvedIncomingGrant, 348 + ): () => Promise<DecryptedBlob> { 349 + return () => decryptIncomingBlob(resolved); 231 350 }
+6 -1
web/src/routes/cabinet/files/route.tsx
··· 21 21 import { PanelSkeleton } from "@/components/cabinet/PanelSkeleton"; 22 22 import { PreviewPaneHeader } from "@/components/cabinet/PreviewPaneHeader"; 23 23 import { FilePreview, evictPreviewCache } from "@/components/cabinet/FilePreview"; 24 + import { decryptOwnDocument } from "@/lib/preview"; 24 25 import { TagFilterBar } from "@/components/cabinet/TagFilterBar"; 25 26 import { NewFolderDialog, type NewFolderDialogHandle } from "@/components/cabinet/NewFolderDialog"; 26 27 import { useDocumentsStore } from "@/stores/documents"; ··· 285 286 /> 286 287 <div className="min-h-0 flex-1 overflow-hidden"> 287 288 <Suspense fallback={<PanelSkeleton />}> 288 - <FilePreview documentUri={currentDocumentUri} /> 289 + <FilePreview 290 + cacheKey={currentDocumentUri} 291 + decrypt={decryptOwnDocument(currentDocumentUri)} 292 + onDownload={() => void downloadFile(currentDocumentUri)} 293 + /> 289 294 </Suspense> 290 295 </div> 291 296 </>
+512 -96
web/src/routes/cabinet/shared.tsx
··· 1 - import { useCallback, useEffect, useState } from "react"; 2 - import { createFileRoute } from "@tanstack/react-router"; 3 - import { UsersIcon, ShareNetworkIcon, TrashIcon, ArrowSquareOutIcon } from "@phosphor-icons/react"; 1 + import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 + import { 4 + UsersIcon, 5 + ListBulletsIcon, 6 + SquaresFourIcon, 7 + DotsThreeVerticalIcon, 8 + ProhibitIcon, 9 + DownloadSimpleIcon, 10 + FolderOpenIcon, 11 + } from "@phosphor-icons/react"; 4 12 import { PanelShell } from "@/components/cabinet/PanelShell"; 13 + import { PanelSkeleton } from "@/components/cabinet/PanelSkeleton"; 14 + import { PreviewPaneHeader } from "@/components/cabinet/PreviewPaneHeader"; 15 + import { FilePreview, evictPreviewCache } from "@/components/cabinet/FilePreview"; 16 + import { Breadcrumbs, BreadcrumbActive } from "@/components/cabinet/Breadcrumbs"; 17 + import { SegmentedToggle } from "@/components/SegmentedToggle"; 18 + import { DropdownMenu } from "@/components/DropdownMenu"; 19 + import { FileListRow } from "@/components/cabinet/FileListRow"; 20 + import { FileGridCard } from "@/components/cabinet/FileGridCard"; 21 + import { RevokeShareDialog } from "@/components/cabinet/RevokeShareDialog"; 22 + import type { ConfirmDialogHandle } from "@/components/ConfirmDialog"; 23 + import { isPreviewable } from "@/components/cabinet/types"; 5 24 import { useAuthStore } from "@/stores/auth"; 25 + import { useDocumentsStore } from "@/stores/documents"; 26 + import { decryptDocumentRecord } from "@/stores/documents/decrypt"; 6 27 import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 7 - import { truncateDid, formatShortDate } from "@/lib/format"; 28 + import { truncateDid, formatRelativeDate, mimeTypeToFileType, formatFileSize } from "@/lib/format"; 29 + import { handleFromDid, pdsUrlFromDid } from "@/lib/did"; 30 + import { loading as trackLoading } from "@/stores/app"; 8 31 import { 9 32 listOutgoingGrants, 10 33 listIncomingGrants, 11 34 revokeGrant, 35 + resolveIncomingGrant, 36 + downloadIncomingGrant, 37 + decryptIncomingDocument, 12 38 type GrantEntry, 13 39 type InboxGrantItem, 40 + type ResolvedIncomingGrant, 14 41 } from "@/lib/sharing"; 42 + import { decryptOwnDocument } from "@/lib/preview"; 43 + import { base64ToUint8Array } from "@/lib/encoding"; 15 44 import type { OAuthSession } from "@/lib/storageTypes"; 45 + import type { FileItem } from "@/components/cabinet/types"; 16 46 import { toastSuccess, toastError } from "@/stores/toast"; 17 47 18 48 const storage = new IndexedDbStorage(); 19 49 20 50 // --------------------------------------------------------------------------- 21 - // Outgoing Grants Section 51 + // Handle resolution cache 22 52 // --------------------------------------------------------------------------- 23 53 24 - function OutgoingGrantsSection({ 25 - grants, 26 - onRevoke, 27 - }: { 28 - readonly grants: readonly GrantEntry[]; 29 - readonly onRevoke: (uri: string) => void; 30 - }) { 31 - if (grants.length === 0) return null; 54 + type HandleCache = Readonly<Record<string, string>>; 32 55 33 - return ( 34 - <section className="mb-6"> 35 - <h3 className="text-label text-text-faint mb-2 ml-1 tracking-widest uppercase"> 36 - Shared by you 37 - </h3> 38 - <div className="flex flex-col gap-1"> 39 - {grants.map((grant) => ( 40 - <div 41 - key={grant.uri} 42 - className="hover:bg-bg-hover flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors" 43 - > 44 - <div className="bg-primary/10 flex size-8 shrink-0 items-center justify-center rounded-lg"> 45 - <ShareNetworkIcon size={14} className="text-primary" /> 46 - </div> 47 - <div className="min-w-0 flex-1"> 48 - <div className="text-ui text-base-content truncate"> 49 - → {truncateDid(grant.record.recipient)} 50 - </div> 51 - <div className="text-caption text-text-faint mt-0.5"> 52 - {formatShortDate(grant.record.createdAt)} 53 - </div> 54 - </div> 55 - <button 56 - onClick={() => onRevoke(grant.uri)} 57 - className="btn btn-ghost btn-xs btn-square rounded-md" 58 - aria-label={`Revoke grant to ${grant.record.recipient}`} 59 - > 60 - <TrashIcon size={13} className="text-error" /> 61 - </button> 62 - </div> 63 - ))} 64 - </div> 65 - </section> 66 - ); 56 + function useHandleResolver(dids: readonly string[]): HandleCache { 57 + const [cache, setCache] = useState<HandleCache>({}); 58 + const resolvedRef = useRef<Set<string>>(new Set()); 59 + 60 + useEffect(() => { 61 + const unresolved = dids.filter((did) => !resolvedRef.current.has(did)); 62 + if (unresolved.length === 0) return; 63 + 64 + unresolved.forEach((did) => { 65 + resolvedRef.current.add(did); 66 + void handleFromDid(did).then((handle) => { 67 + if (handle) { 68 + setCache((prev) => ({ ...prev, [did]: handle })); 69 + } 70 + }); 71 + }); 72 + }, [dids]); 73 + 74 + return cache; 67 75 } 68 76 69 77 // --------------------------------------------------------------------------- 70 - // Incoming Grants Section 78 + // Grant → FileItem conversion 71 79 // --------------------------------------------------------------------------- 72 80 73 - function IncomingGrantsSection({ grants }: { readonly grants: readonly InboxGrantItem[] }) { 74 - if (grants.length === 0) return null; 81 + function outgoingGrantToFileItem( 82 + grant: GrantEntry, 83 + storeItem: FileItem | undefined, 84 + recipientDisplay: string, 85 + ): FileItem { 86 + const resolved = storeItem?.decrypted === true ? storeItem : undefined; 87 + return { 88 + id: grant.uri, 89 + uri: grant.uri, 90 + name: resolved?.name ?? "Encrypted file", 91 + kind: "file", 92 + fileType: resolved?.fileType, 93 + mimeType: resolved?.mimeType, 94 + encrypted: true, 95 + status: "shared", 96 + size: resolved?.size, 97 + modified: formatRelativeDate(grant.record.createdAt), 98 + decrypted: true, 99 + tags: [], 100 + subtitle: `shared with ${recipientDisplay}`, 101 + }; 102 + } 75 103 76 - return ( 77 - <section className="mb-6"> 78 - <h3 className="text-label text-text-faint mb-2 ml-1 tracking-widest uppercase"> 79 - Shared with you 80 - </h3> 81 - <div className="flex flex-col gap-1"> 82 - {grants.map((grant) => ( 83 - <div 84 - key={grant.uri} 85 - className="hover:bg-bg-hover flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors" 86 - > 87 - <div className="bg-success/10 flex size-8 shrink-0 items-center justify-center rounded-lg"> 88 - <UsersIcon size={14} className="text-success" /> 89 - </div> 90 - <div className="min-w-0 flex-1"> 91 - <div className="text-ui text-base-content truncate"> 92 - From {truncateDid(grant.ownerDid)} 93 - </div> 94 - <div className="text-caption text-text-faint mt-0.5"> 95 - {formatShortDate(grant.createdAt)} 96 - </div> 97 - </div> 98 - <ArrowSquareOutIcon size={13} className="text-text-faint shrink-0" /> 99 - </div> 100 - ))} 101 - </div> 102 - </section> 103 - ); 104 + function incomingGrantToFileItem( 105 + grant: InboxGrantItem, 106 + ownerDisplay: string, 107 + resolved?: ResolvedIncomingGrant, 108 + ): FileItem { 109 + return { 110 + id: grant.uri, 111 + uri: grant.uri, 112 + name: resolved?.metadata.name ?? "Shared file", 113 + kind: "file", 114 + fileType: resolved?.metadata.mimeType 115 + ? mimeTypeToFileType(resolved.metadata.mimeType) 116 + : undefined, 117 + mimeType: resolved?.metadata.mimeType ?? undefined, 118 + size: resolved?.metadata.size != null ? formatFileSize(resolved.metadata.size) : undefined, 119 + encrypted: true, 120 + status: "shared", 121 + modified: formatRelativeDate(grant.createdAt), 122 + decrypted: resolved !== undefined, 123 + tags: [], 124 + subtitle: `from ${ownerDisplay}`, 125 + }; 104 126 } 127 + 128 + // --------------------------------------------------------------------------- 129 + // Preview source discriminator 130 + // --------------------------------------------------------------------------- 131 + 132 + type PreviewTarget = 133 + | { readonly source: "outgoing"; readonly documentUri: string; readonly item: FileItem } 134 + | { 135 + readonly source: "incoming"; 136 + readonly grant: ResolvedIncomingGrant; 137 + readonly item: FileItem; 138 + }; 105 139 106 140 // --------------------------------------------------------------------------- 107 141 // Page 108 142 // --------------------------------------------------------------------------- 109 143 144 + // eslint-disable-next-line sonarjs/cognitive-complexity -- layout component with split-panel preview; splitting further would obscure the data flow 110 145 function SharedPage() { 111 146 const session = useAuthStore((s) => s.session); 147 + const storeItems = useDocumentsStore((s) => s.items); 148 + const documentRecords = useDocumentsStore((s) => s.documentRecords); 149 + const fetchAll = useDocumentsStore((s) => s.fetchAll); 150 + const cabinetPathFor = useDocumentsStore((s) => s.cabinetPathFor); 151 + const viewMode = useDocumentsStore((s) => s.viewMode); 152 + const setViewMode = useDocumentsStore((s) => s.setViewMode); 153 + const downloadFile = useDocumentsStore((s) => s.downloadFile); 154 + const navigate = useNavigate(); 155 + 156 + const revokeDialogRef = useRef<ConfirmDialogHandle>(null); 157 + 112 158 const [outgoing, setOutgoing] = useState<GrantEntry[]>([]); 113 159 const [incoming, setIncoming] = useState<InboxGrantItem[]>([]); 160 + const [resolvedIncoming, setResolvedIncoming] = useState<Record<string, ResolvedIncomingGrant>>( 161 + {}, 162 + ); 114 163 const [loading, setLoading] = useState(true); 164 + const [preview, setPreview] = useState<PreviewTarget | null>(null); 165 + 166 + // Handle resolution 167 + const recipientDids = useMemo(() => outgoing.map((g) => g.record.recipient), [outgoing]); 168 + const ownerDids = useMemo(() => incoming.map((g) => g.ownerDid), [incoming]); 169 + const allDids = useMemo(() => [...recipientDids, ...ownerDids], [recipientDids, ownerDids]); 170 + const handleCache = useHandleResolver(allDids); 171 + 172 + // Grant URI → GrantEntry lookup 173 + const grantMap = useMemo(() => new Map(outgoing.map((g) => [g.uri, g])), [outgoing]); 174 + 175 + // Build FileItem arrays 176 + const outgoingItems: readonly FileItem[] = useMemo( 177 + () => 178 + outgoing.map((grant) => { 179 + const recipientDisplay = 180 + handleCache[grant.record.recipient] ?? truncateDid(grant.record.recipient); 181 + return outgoingGrantToFileItem(grant, storeItems[grant.record.document], recipientDisplay); 182 + }), 183 + [outgoing, handleCache, storeItems], 184 + ); 185 + 186 + const incomingItems: readonly FileItem[] = useMemo( 187 + () => 188 + incoming.map((grant) => { 189 + const ownerDisplay = handleCache[grant.ownerDid] ?? truncateDid(grant.ownerDid); 190 + return incomingGrantToFileItem(grant, ownerDisplay, resolvedIncoming[grant.uri]); 191 + }), 192 + [incoming, handleCache, resolvedIncoming], 193 + ); 115 194 116 195 const fetchGrants = useCallback(async () => { 117 196 if (session.status !== "active") { ··· 119 198 return; 120 199 } 121 200 201 + const done = trackLoading("sharing-fetch"); 122 202 setLoading(true); 123 203 try { 124 204 const oauthSession = (await storage.loadSession(session.did)) as OAuthSession; ··· 128 208 listIncomingGrants(session.did), 129 209 ]); 130 210 211 + // REMOVE — hard-coded test grant 212 + const testIncoming: InboxGrantItem = { 213 + uri: "at://did:plc:jgevbp3tq46mkjcavmwfitgb/app.opake.grant/3mgnw5blxtn22", 214 + ownerDid: "did:plc:jgevbp3tq46mkjcavmwfitgb", 215 + documentUri: "", 216 + createdAt: new Date().toISOString(), 217 + }; 218 + const allIncoming = [testIncoming, ...inc]; 219 + 131 220 setOutgoing(out); 132 - setIncoming(inc); 221 + setIncoming(allIncoming); 222 + 223 + // Pre-resolve unique owner PDS URLs, then resolve each grant 224 + const identity = await storage.loadIdentity(session.did); 225 + const privateKey = base64ToUint8Array(identity.private_key); 226 + const uniqueOwnerDids = [...new Set(allIncoming.map((g) => g.ownerDid))]; 227 + const pdsResults = await Promise.all( 228 + uniqueOwnerDids.map((did) => 229 + pdsUrlFromDid(did) 230 + .then((url) => [did, url] as const) 231 + .catch(() => null), 232 + ), 233 + ); 234 + const pdsUrlCache = new Map(pdsResults.filter((r): r is NonNullable<typeof r> => r !== null)); 235 + 236 + allIncoming.forEach((grant) => { 237 + const ownerPds = pdsUrlCache.get(grant.ownerDid); 238 + if (!ownerPds) return; 239 + void resolveIncomingGrant(grant, privateKey, ownerPds) 240 + .then((resolved) => { 241 + setResolvedIncoming((prev) => ({ ...prev, [grant.uri]: resolved })); 242 + }) 243 + .catch((err: unknown) => { 244 + console.warn("[shared] failed to resolve incoming grant:", grant.uri, err); 245 + }); 246 + }); 133 247 } catch (error) { 134 248 console.error("[shared] failed to load grants:", error); 135 249 } finally { 136 250 setLoading(false); 251 + done(); 137 252 } 138 253 }, [session]); 139 254 ··· 141 256 void fetchGrants(); 142 257 }, [fetchGrants]); 143 258 259 + // Ensure documents store is loaded so outgoing grant names resolve 260 + useEffect(() => { 261 + void fetchAll(); 262 + }, [fetchAll]); 263 + 264 + // Decrypt metadata for outgoing grant documents that the store hasn't decrypted yet 265 + useEffect(() => { 266 + if (outgoing.length === 0 || session.status !== "active") return; 267 + 268 + const undecrypted = outgoing.filter((g) => { 269 + const item = storeItems[g.record.document]; 270 + const record = documentRecords[g.record.document]; 271 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard on dynamic key 272 + return record && !item?.decrypted; 273 + }); 274 + if (undecrypted.length === 0) return; 275 + 276 + const setItems: (fn: (draft: { items: Record<string, FileItem> }) => void) => void = (fn) => { 277 + useDocumentsStore.setState((state) => { 278 + const draft = { items: { ...state.items } }; 279 + fn(draft); 280 + return { items: draft.items }; 281 + }); 282 + }; 283 + 284 + void (async () => { 285 + const identity = await storage.loadIdentity(session.did); 286 + const privateKey = base64ToUint8Array(identity.private_key); 287 + await Promise.all( 288 + undecrypted.map((g) => 289 + decryptDocumentRecord( 290 + documentRecords[g.record.document], 291 + session.did, 292 + privateKey, 293 + setItems, 294 + ).catch((err: unknown) => 295 + console.warn("[shared] failed to decrypt grant document:", g.uri, err), 296 + ), 297 + ), 298 + ); 299 + })(); 300 + }, [outgoing, storeItems, documentRecords, session]); 301 + 302 + // Evict preview cache on close 303 + const previewCacheKey = preview?.source === "outgoing" ? preview.documentUri : preview?.grant.uri; 304 + useEffect(() => { 305 + if (!previewCacheKey) return undefined; 306 + const key = previewCacheKey; 307 + return () => evictPreviewCache(key); 308 + }, [previewCacheKey]); 309 + 144 310 const handleRevoke = useCallback( 145 311 async (grantUri: string) => { 146 312 if (session.status !== "active") return; 147 313 314 + const done = trackLoading(`revoke:${grantUri}`); 148 315 try { 149 316 const oauthSession = (await storage.loadSession(session.did)) as OAuthSession; 150 317 await revokeGrant(session.pdsUrl, session.did, grantUri, oauthSession); 151 318 setOutgoing((prev) => prev.filter((g) => g.uri !== grantUri)); 152 - toastSuccess("Grant revoked"); 319 + if (preview?.item.uri === grantUri) setPreview(null); 320 + toastSuccess("Sharing stopped"); 153 321 } catch (error) { 154 322 const message = error instanceof Error ? error.message : "Failed to revoke"; 155 323 toastError(message); 324 + } finally { 325 + done(); 156 326 } 157 327 }, 158 - [session], 328 + [session, preview], 159 329 ); 330 + 331 + const handleDownloadIncoming = useCallback( 332 + async (item: FileItem) => { 333 + const resolved = resolvedIncoming[item.uri]; 334 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard on dynamic key 335 + if (!resolved) return; 336 + 337 + const done = trackLoading(`download:${item.uri}`); 338 + try { 339 + await downloadIncomingGrant(resolved); 340 + toastSuccess("Download started"); 341 + } catch (error) { 342 + const message = error instanceof Error ? error.message : "Download failed"; 343 + toastError(message); 344 + } finally { 345 + done(); 346 + } 347 + }, 348 + [resolvedIncoming], 349 + ); 350 + 351 + // Click handlers 352 + const handleOutgoingClick = useCallback( 353 + (item: FileItem) => { 354 + const grant = grantMap.get(item.uri); 355 + if (!grant) return; 356 + if (isPreviewable(item)) { 357 + setPreview({ source: "outgoing", documentUri: grant.record.document, item }); 358 + } 359 + }, 360 + [grantMap], 361 + ); 362 + 363 + const handleIncomingClick = useCallback( 364 + (item: FileItem) => { 365 + const resolved = resolvedIncoming[item.uri]; 366 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard on dynamic key 367 + if (!resolved) return; 368 + if (isPreviewable(item)) { 369 + setPreview({ source: "incoming", grant: resolved, item }); 370 + } 371 + }, 372 + [resolvedIncoming], 373 + ); 374 + 375 + // Action menu renderers 376 + const renderIncomingActions = useCallback( 377 + (item: FileItem) => () => { 378 + const resolved = resolvedIncoming[item.uri]; 379 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard on dynamic key 380 + if (!resolved) return null; 381 + return ( 382 + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- stopPropagation wrapper 383 + <div onClick={(e) => e.stopPropagation()}> 384 + <DropdownMenu 385 + triggerClassName="btn btn-ghost btn-xs btn-square rounded-md" 386 + trigger={ 387 + <DotsThreeVerticalIcon size={16} weight="bold" className="text-base-content" /> 388 + } 389 + align="right" 390 + items={[ 391 + { 392 + icon: DownloadSimpleIcon, 393 + label: "Download", 394 + onClick: () => void handleDownloadIncoming(item), 395 + }, 396 + ]} 397 + /> 398 + </div> 399 + ); 400 + }, 401 + [resolvedIncoming, handleDownloadIncoming], 402 + ); 403 + 404 + const renderOutgoingActions = useCallback( 405 + (item: FileItem) => () => { 406 + const grant = grantMap.get(item.uri); 407 + const cabinetPath = grant ? cabinetPathFor(grant.record.document) : null; 408 + return ( 409 + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- stopPropagation wrapper 410 + <div onClick={(e) => e.stopPropagation()}> 411 + <DropdownMenu 412 + triggerClassName="btn btn-ghost btn-xs btn-square rounded-md" 413 + trigger={ 414 + <DotsThreeVerticalIcon size={16} weight="bold" className="text-base-content" /> 415 + } 416 + align="right" 417 + items={[ 418 + ...(cabinetPath 419 + ? [ 420 + { 421 + icon: FolderOpenIcon, 422 + label: "Show in cabinet", 423 + onClick: () => 424 + void navigate({ 425 + to: "/cabinet/files/$", 426 + params: { _splat: cabinetPath }, 427 + }), 428 + }, 429 + ] 430 + : []), 431 + { 432 + icon: DownloadSimpleIcon, 433 + label: "Download", 434 + onClick: () => { 435 + if (grant) void downloadFile(grant.record.document); 436 + }, 437 + }, 438 + { 439 + icon: ProhibitIcon, 440 + label: "Stop sharing", 441 + onClick: () => { 442 + if (grant) revokeDialogRef.current?.show(grant.uri, item.name); 443 + }, 444 + }, 445 + ]} 446 + /> 447 + </div> 448 + ); 449 + }, 450 + [grantMap, cabinetPathFor, downloadFile, navigate], 451 + ); 452 + 453 + const FileComponent = viewMode === "list" ? FileListRow : FileGridCard; 160 454 161 455 const breadcrumbs = ( 162 - <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 163 - <ul> 164 - <li> 165 - <span className="text-base-content font-medium">Shared with me</span> 166 - </li> 167 - </ul> 168 - </div> 456 + <Breadcrumbs> 457 + <BreadcrumbActive>Sharing</BreadcrumbActive> 458 + </Breadcrumbs> 169 459 ); 170 460 171 - const isEmpty = outgoing.length === 0 && incoming.length === 0; 461 + const toolbar = ( 462 + <SegmentedToggle 463 + options={[ 464 + { value: "list" as const, icon: ListBulletsIcon }, 465 + { value: "grid" as const, icon: SquaresFourIcon }, 466 + ]} 467 + value={viewMode} 468 + onChange={setViewMode} 469 + /> 470 + ); 471 + 472 + const totalCount = outgoing.length + incoming.length; 473 + const isEmpty = totalCount === 0; 474 + 475 + // Preview side panel 476 + const handlePreviewDownload = () => { 477 + if (!preview) return; 478 + if (preview.source === "outgoing") { 479 + void downloadFile(preview.documentUri); 480 + } else { 481 + void handleDownloadIncoming(preview.item); 482 + } 483 + }; 484 + 485 + const previewDecrypt = 486 + preview?.source === "outgoing" 487 + ? decryptOwnDocument(preview.documentUri) 488 + : preview?.source === "incoming" 489 + ? decryptIncomingDocument(preview.grant) 490 + : undefined; 491 + 492 + const previewPanel = 493 + preview && previewCacheKey && previewDecrypt ? ( 494 + <> 495 + <PreviewPaneHeader 496 + documentName={preview.item.decrypted ? preview.item.name : null} 497 + onDownload={handlePreviewDownload} 498 + onClose={() => setPreview(null)} 499 + /> 500 + <div className="min-h-0 flex-1 overflow-hidden"> 501 + <Suspense fallback={<PanelSkeleton />}> 502 + <FilePreview 503 + cacheKey={previewCacheKey} 504 + decrypt={previewDecrypt} 505 + onDownload={handlePreviewDownload} 506 + /> 507 + </Suspense> 508 + </div> 509 + </> 510 + ) : undefined; 172 511 173 512 return ( 174 - <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Shared items · Encrypted"> 513 + <PanelShell 514 + depth={1} 515 + breadcrumbs={breadcrumbs} 516 + toolbar={toolbar} 517 + footer={`${totalCount} shared ${totalCount === 1 ? "item" : "items"} · Encrypted`} 518 + sidePanel={previewPanel} 519 + > 175 520 <div 176 521 role="alert" 177 522 className="alert border-success/30 bg-bg-sage mx-4 mt-4 gap-2.5 rounded-xl p-3" ··· 179 524 <UsersIcon size={13} className="text-success mt-0.5 shrink-0" /> 180 525 <div> 181 526 <div className="text-success mb-0.5 text-xs font-medium"> 182 - Shared via decentralised identity 527 + End-to-end encrypted sharing 183 528 </div> 184 529 <div className="text-caption text-success/80 leading-relaxed"> 185 - Files shared via DID. Encrypted in transit and at rest — only invited parties can 186 - decrypt. 530 + Only you and the people you share with can see these files. 187 531 </div> 188 532 </div> 189 533 </div> 190 534 191 - <div className="p-4"> 535 + <div className="p-3"> 192 536 {loading ? ( 193 537 <div className="flex justify-center py-12"> 194 538 <span className="loading loading-spinner loading-md text-text-faint" /> ··· 207 551 </div> 208 552 ) : ( 209 553 <> 210 - <OutgoingGrantsSection grants={outgoing} onRevoke={(uri) => void handleRevoke(uri)} /> 211 - <IncomingGrantsSection grants={incoming} /> 554 + {outgoingItems.length > 0 && ( 555 + <section className="mb-4"> 556 + <h3 className="text-label text-text-faint mb-2 ml-1 tracking-widest uppercase"> 557 + Shared by you 558 + </h3> 559 + {viewMode === "list" ? ( 560 + <div className="flex flex-col gap-px"> 561 + {outgoingItems.map((item) => ( 562 + <FileComponent 563 + key={item.id} 564 + item={item} 565 + isActive={preview?.item.uri === item.uri} 566 + onClick={() => handleOutgoingClick(item)} 567 + hideStatus 568 + renderActions={renderOutgoingActions(item)} 569 + /> 570 + ))} 571 + </div> 572 + ) : ( 573 + <div className="grid grid-cols-2 gap-3"> 574 + {outgoingItems.map((item) => ( 575 + <FileComponent 576 + key={item.id} 577 + item={item} 578 + isActive={preview?.item.uri === item.uri} 579 + onClick={() => handleOutgoingClick(item)} 580 + hideStatus 581 + renderActions={renderOutgoingActions(item)} 582 + /> 583 + ))} 584 + </div> 585 + )} 586 + </section> 587 + )} 588 + 589 + {incomingItems.length > 0 && ( 590 + <section className="mb-4"> 591 + <h3 className="text-label text-text-faint mb-2 ml-1 tracking-widest uppercase"> 592 + Shared with you 593 + </h3> 594 + {viewMode === "list" ? ( 595 + <div className="flex flex-col gap-px"> 596 + {incomingItems.map((item) => ( 597 + <FileComponent 598 + key={item.id} 599 + item={item} 600 + isActive={preview?.item.uri === item.uri} 601 + onClick={() => handleIncomingClick(item)} 602 + hideStatus 603 + renderActions={renderIncomingActions(item)} 604 + /> 605 + ))} 606 + </div> 607 + ) : ( 608 + <div className="grid grid-cols-2 gap-3"> 609 + {incomingItems.map((item) => ( 610 + <FileComponent 611 + key={item.id} 612 + item={item} 613 + isActive={preview?.item.uri === item.uri} 614 + onClick={() => handleIncomingClick(item)} 615 + hideStatus 616 + renderActions={renderIncomingActions(item)} 617 + /> 618 + ))} 619 + </div> 620 + )} 621 + </section> 622 + )} 212 623 </> 213 624 )} 214 625 </div> 626 + 627 + <RevokeShareDialog 628 + ref={revokeDialogRef} 629 + onConfirm={(grantUri) => void handleRevoke(grantUri)} 630 + /> 215 631 </PanelShell> 216 632 ); 217 633 }
+2 -2
web/src/stores/documents/file-items.ts
··· 24 24 }; 25 25 } 26 26 27 - export function documentPlaceholder(record: PdsRecord<DocumentRecord>): FileItem { 27 + export function documentPlaceholder(record: PdsRecord<DocumentRecord>, shared: boolean): FileItem { 28 28 return { 29 29 id: record.uri, 30 30 uri: record.uri, 31 31 name: "", 32 32 kind: "file", 33 33 encrypted: true, 34 - status: "private", 34 + status: shared ? "shared" : "private", 35 35 modified: formatRelativeDate(record.value.modifiedAt ?? record.value.createdAt), 36 36 decrypted: false, 37 37 tags: [],
+32 -2
web/src/stores/documents/store.ts
··· 64 64 readonly moveEntry: (entryUri: string, targetDirectoryUri: string | null) => Promise<void>; 65 65 readonly renameDirectory: (directoryUri: string, newName: string) => Promise<void>; 66 66 readonly ancestorsOf: (directoryUri: string | null) => readonly DirectoryAncestor[]; 67 + /** Build the cabinet files route splat path for a document URI, or null if not in the tree. */ 68 + readonly cabinetPathFor: (documentUri: string) => string | null; 67 69 } 68 70 69 71 // --------------------------------------------------------------------------- ··· 107 109 const identity = await storage.loadIdentity(did); 108 110 const privateKey = base64ToUint8Array(identity.private_key); 109 111 110 - const [documentRecords, directoryRecords] = await Promise.all([ 112 + const [documentRecords, directoryRecords, grantRecords] = await Promise.all([ 111 113 fetchAllRecords<DocumentRecord>(pdsUrl, did, "app.opake.document", session), 112 114 fetchAllRecords<DirectoryRecord>(pdsUrl, did, "app.opake.directory", session), 115 + fetchAllRecords<{ document: string }>(pdsUrl, did, "app.opake.grant", session), 113 116 ]); 117 + 118 + // Collect document URIs that have at least one outgoing grant 119 + const sharedDocumentUris = new Set(grantRecords.map((r) => r.value.document)); 114 120 115 121 // Build directory tree in WASM — decrypts all directory names in one call 116 122 const worker = getCryptoWorker(); ··· 138 144 ); 139 145 140 146 // Create placeholder FileItems for all documents 141 - const documentItems = documentRecords.map((r) => [r.uri, documentPlaceholder(r)] as const); 147 + const documentItems = documentRecords.map( 148 + (r) => [r.uri, documentPlaceholder(r, sharedDocumentUris.has(r.uri))] as const, 149 + ); 142 150 143 151 const items: Readonly<Record<string, FileItem>> = Object.fromEntries([ 144 152 ...directoryItems, ··· 617 625 }; 618 626 619 627 return collectAncestors(directoryUri, []); 628 + }, 629 + 630 + cabinetPathFor: (documentUri: string): string | null => { 631 + const { treeSnapshot } = get(); 632 + if (!treeSnapshot) return null; 633 + 634 + // Find the parent directory containing this document 635 + const parentUri = 636 + Object.entries(treeSnapshot.directories).find(([, entry]) => 637 + entry.entries.includes(documentUri), 638 + )?.[0] ?? null; 639 + 640 + const docRkey = rkeyFromUri(documentUri); 641 + 642 + // Document is in the root directory — path is just the rkey 643 + if (!parentUri || parentUri === treeSnapshot.rootUri) return docRkey; 644 + 645 + // Build ancestor chain from root to parent directory 646 + const ancestors = get().ancestorsOf(parentUri); 647 + const parentRkey = rkeyFromUri(parentUri); 648 + const segments = [...ancestors.map((a) => a.rkey), parentRkey, docRkey]; 649 + return segments.join("/"); 620 650 }, 621 651 })), 622 652 );
+19
web/src/workers/crypto.worker.ts
··· 18 18 generatePkce as wasmGeneratePkce, 19 19 generateIdentity as wasmGenerateIdentity, 20 20 generateEphemeralKeypair as wasmGenerateEphemeralKeypair, 21 + didDocumentUrl as wasmDidDocumentUrl, 22 + handleFromDidDocument as wasmHandleFromDidDocument, 23 + pdsFromDidDocument as wasmPdsFromDidDocument, 21 24 DirectoryTreeHandle, 22 25 } from "@/wasm/opake-wasm/opake"; 23 26 import type { ··· 144 147 145 148 generateEphemeralKeypair(): EphemeralKeypair { 146 149 return wasmGenerateEphemeralKeypair() as EphemeralKeypair; 150 + }, 151 + 152 + // --------------------------------------------------------------------------- 153 + // DID document utilities 154 + // --------------------------------------------------------------------------- 155 + 156 + didDocumentUrl(did: string): string { 157 + return wasmDidDocumentUrl(did); 158 + }, 159 + 160 + handleFromDidDocument(docJson: Uint8Array): string | undefined { 161 + return wasmHandleFromDidDocument(docJson); 162 + }, 163 + 164 + pdsFromDidDocument(docJson: Uint8Array): string { 165 + return wasmPdsFromDidDocument(docJson); 147 166 }, 148 167 149 168 // ---------------------------------------------------------------------------