perf(listing): return per-item is_favorite/is_shared, drop client badge fetches

The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.

Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
  level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
  concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
  `PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
  which already covers public links as 'token' grants — same membership the
  /grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).

Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
  badge sets straight from the listing and updates them optimistically on
  favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
  the single, authoritative, fetch-free source.

Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
Claude
2026-06-19 15:21:01 +00:00
parent 546dcef305
commit 9ccaeef0ab
9 changed files with 156 additions and 174 deletions
+54 -2
View File
@@ -169,6 +169,8 @@ impl FolderHandler {
fn compute_listing_etag(
folders: &[crate::application::dtos::folder_dto::FolderDto],
files: &[crate::application::dtos::file_dto::FileDto],
favorite_ids: &[String],
shared_ids: &[String],
) -> String {
let max_mod = folders
.iter()
@@ -180,6 +182,10 @@ impl FolderHandler {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
max_mod.hash(&mut hasher);
count.hash(&mut hasher);
// Badge state is part of the representation — fold it in (both slices are
// sorted, so the hash is stable) so a favorite/share change busts the ETag.
favorite_ids.hash(&mut hasher);
shared_ids.hash(&mut hasher);
format!("\"{:x}\"", hasher.finish())
}
@@ -205,7 +211,48 @@ impl FolderHandler {
match (folders_result, files_result) {
(Ok(folders), Ok(files)) => {
let etag = Self::compute_listing_etag(&folders, &files);
// Badge enrichment for this listing: which items the caller has
// favorited / shared. Two batched, index-backed queries (run
// concurrently) replace the client's old per-navigation global
// favorites + outgoing-shares fetches — correct (no 200-item
// ceiling) and scoped to just the items on screen.
let fav_pairs: Vec<(&str, &str)> = folders
.iter()
.map(|f| (f.id.as_str(), "folder"))
.chain(files.iter().map(|f| (f.id.as_str(), "file")))
.collect();
let resource_uuids: Vec<uuid::Uuid> = folders
.iter()
.map(|f| f.id.as_str())
.chain(files.iter().map(|f| f.id.as_str()))
.filter_map(|s| uuid::Uuid::parse_str(s).ok())
.collect();
let (favorited, shared) = tokio::join!(
async {
match &state.favorites_service {
Some(svc) => svc
.favorited_ids(auth_user.id, &fav_pairs)
.await
.unwrap_or_default(),
None => Default::default(),
}
},
state
.authorization
.shared_resource_ids(auth_user.id, &resource_uuids)
);
let mut favorite_ids: Vec<String> = favorited.into_iter().collect();
favorite_ids.sort();
let mut shared_ids: Vec<String> = shared
.unwrap_or_default()
.into_iter()
.map(|u| u.to_string())
.collect();
shared_ids.sort();
let etag = Self::compute_listing_etag(&folders, &files, &favorite_ids, &shared_ids);
// 304 Not Modified if the client already has this version
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
@@ -219,7 +266,12 @@ impl FolderHandler {
.unwrap()
.into_response();
}
let listing = FolderListingDto { folders, files };
let listing = FolderListingDto {
folders,
files,
favorite_ids,
shared_ids,
};
let mut resp = (StatusCode::OK, Json(listing)).into_response();
resp.headers_mut()
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());