Highly ambitious ATProtocol AppView service and sdks

improve waitlist ui with search and load more, update getActors in api to support same where as getRecords

+687 -272
+146 -190
api/src/database/actors.rs
··· 4 4 //! tracked within slices, including batch insertion, querying, and filtering. 5 5 6 6 use super::client::Database; 7 - use super::types::WhereCondition; 7 + use super::types::{WhereClause, WhereCondition}; 8 8 use crate::errors::DatabaseError; 9 9 use crate::models::Actor; 10 - use std::collections::HashMap; 11 10 12 11 impl Database { 13 12 /// Inserts multiple actors in batches with conflict resolution. ··· 46 45 Ok(()) 47 46 } 48 47 49 - /// Queries actors for a slice with optional filtering and cursor-based pagination. 48 + /// Queries actors for a slice with advanced filtering and cursor-based pagination. 50 49 /// 51 - /// Supports filtering by: 52 - /// - handle (exact match or contains) 53 - /// - did (exact match or IN clause) 50 + /// Supports: 51 + /// - Complex WHERE conditions (AND/OR, eq/in/contains operators) 52 + /// - Cursor-based pagination 54 53 /// 55 54 /// # Returns 56 55 /// Tuple of (actors, next_cursor) where cursor is the last DID ··· 59 58 slice_uri: &str, 60 59 limit: Option<i32>, 61 60 cursor: Option<&str>, 62 - where_conditions: Option<&HashMap<String, WhereCondition>>, 61 + where_clause: Option<&WhereClause>, 63 62 ) -> Result<(Vec<Actor>, Option<String>), DatabaseError> { 64 63 let limit = limit.unwrap_or(50).min(100); 65 64 66 - let records = if let Some(conditions) = where_conditions { 67 - if let Some(handle_condition) = conditions.get("handle") { 68 - if let Some(contains_value) = &handle_condition.contains { 69 - let pattern = format!("%{}%", contains_value); 70 - if let Some(cursor_did) = cursor { 71 - sqlx::query_as!( 72 - Actor, 73 - r#" 74 - SELECT did, handle, slice_uri, indexed_at 75 - FROM actor 76 - WHERE slice_uri = $1 AND handle ILIKE $2 AND did > $3 77 - ORDER BY did ASC 78 - LIMIT $4 79 - "#, 80 - slice_uri, 81 - pattern, 82 - cursor_did, 83 - limit as i64 84 - ) 85 - .fetch_all(&self.pool) 86 - .await? 87 - } else { 88 - sqlx::query_as!( 89 - Actor, 90 - r#" 91 - SELECT did, handle, slice_uri, indexed_at 92 - FROM actor 93 - WHERE slice_uri = $1 AND handle ILIKE $2 94 - ORDER BY did ASC 95 - LIMIT $3 96 - "#, 97 - slice_uri, 98 - pattern, 99 - limit as i64 100 - ) 101 - .fetch_all(&self.pool) 102 - .await? 103 - } 104 - } else if let Some(eq_value) = &handle_condition.eq { 105 - let handle_str = eq_value.as_str().unwrap_or(""); 106 - if let Some(cursor_did) = cursor { 107 - sqlx::query_as!( 108 - Actor, 109 - r#" 110 - SELECT did, handle, slice_uri, indexed_at 111 - FROM actor 112 - WHERE slice_uri = $1 AND handle = $2 AND did > $3 113 - ORDER BY did ASC 114 - LIMIT $4 115 - "#, 116 - slice_uri, 117 - handle_str, 118 - cursor_did, 119 - limit as i64 120 - ) 121 - .fetch_all(&self.pool) 122 - .await? 65 + let mut where_clauses = vec![format!("slice_uri = $1")]; 66 + let mut param_count = 2; 67 + 68 + // Build WHERE conditions for actors (handle table columns properly) 69 + let (and_conditions, or_conditions) = build_actor_where_conditions(where_clause, &mut param_count); 70 + where_clauses.extend(and_conditions); 71 + 72 + if !or_conditions.is_empty() { 73 + let or_clause = format!("({})", or_conditions.join(" OR ")); 74 + where_clauses.push(or_clause); 75 + } 76 + 77 + // Add cursor condition 78 + if let Some(_cursor_did) = cursor { 79 + where_clauses.push(format!("did > ${}", param_count)); 80 + param_count += 1; 81 + } 82 + 83 + let where_sql = format!("WHERE {}", where_clauses.join(" AND ")); 84 + 85 + let query = format!( 86 + r#" 87 + SELECT did, handle, slice_uri, indexed_at 88 + FROM actor 89 + {} 90 + ORDER BY did ASC 91 + LIMIT ${} 92 + "#, 93 + where_sql, 94 + param_count 95 + ); 96 + 97 + let mut sqlx_query = sqlx::query_as::<_, Actor>(&query); 98 + 99 + // Bind parameters in order 100 + sqlx_query = sqlx_query.bind(slice_uri); 101 + 102 + // Bind WHERE clause parameters 103 + if let Some(clause) = where_clause { 104 + for condition in clause.conditions.values() { 105 + if let Some(eq_value) = &condition.eq { 106 + if let Some(str_val) = eq_value.as_str() { 107 + sqlx_query = sqlx_query.bind(str_val); 123 108 } else { 124 - sqlx::query_as!( 125 - Actor, 126 - r#" 127 - SELECT did, handle, slice_uri, indexed_at 128 - FROM actor 129 - WHERE slice_uri = $1 AND handle = $2 130 - ORDER BY did ASC 131 - LIMIT $3 132 - "#, 133 - slice_uri, 134 - handle_str, 135 - limit as i64 136 - ) 137 - .fetch_all(&self.pool) 138 - .await? 109 + sqlx_query = sqlx_query.bind(eq_value); 139 110 } 140 - } else { 141 - self.query_actors_with_cursor(slice_uri, cursor, limit) 142 - .await? 143 111 } 144 - } else if let Some(did_condition) = conditions.get("did") { 145 - if let Some(in_values) = &did_condition.in_values { 146 - let string_values: Vec<String> = in_values 112 + if let Some(in_values) = &condition.in_values { 113 + let str_values: Vec<String> = in_values 147 114 .iter() 148 - .filter_map(|v| v.as_str()) 149 - .map(|s| s.to_string()) 115 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 150 116 .collect(); 117 + sqlx_query = sqlx_query.bind(str_values); 118 + } 119 + if let Some(contains_value) = &condition.contains { 120 + sqlx_query = sqlx_query.bind(contains_value); 121 + } 122 + } 151 123 152 - sqlx::query_as!( 153 - Actor, 154 - r#" 155 - SELECT did, handle, slice_uri, indexed_at 156 - FROM actor 157 - WHERE slice_uri = $1 AND did = ANY($2) 158 - ORDER BY did ASC 159 - LIMIT $3 160 - "#, 161 - slice_uri, 162 - &string_values, 163 - limit as i64 164 - ) 165 - .fetch_all(&self.pool) 166 - .await? 167 - } else if let Some(eq_value) = &did_condition.eq { 168 - let did_str = eq_value.as_str().unwrap_or(""); 169 - if let Some(cursor_did) = cursor { 170 - sqlx::query_as!( 171 - Actor, 172 - r#" 173 - SELECT did, handle, slice_uri, indexed_at 174 - FROM actor 175 - WHERE slice_uri = $1 AND did = $2 AND did > $3 176 - ORDER BY did ASC 177 - LIMIT $4 178 - "#, 179 - slice_uri, 180 - did_str, 181 - cursor_did, 182 - limit as i64 183 - ) 184 - .fetch_all(&self.pool) 185 - .await? 186 - } else { 187 - sqlx::query_as!( 188 - Actor, 189 - r#" 190 - SELECT did, handle, slice_uri, indexed_at 191 - FROM actor 192 - WHERE slice_uri = $1 AND did = $2 193 - ORDER BY did ASC 194 - LIMIT $3 195 - "#, 196 - slice_uri, 197 - did_str, 198 - limit as i64 199 - ) 200 - .fetch_all(&self.pool) 201 - .await? 124 + // Bind OR conditions 125 + if let Some(or_conditions) = &clause.or_conditions { 126 + for condition in or_conditions.values() { 127 + if let Some(eq_value) = &condition.eq { 128 + if let Some(str_val) = eq_value.as_str() { 129 + sqlx_query = sqlx_query.bind(str_val); 130 + } else { 131 + sqlx_query = sqlx_query.bind(eq_value); 132 + } 202 133 } 203 - } else { 204 - self.query_actors_with_cursor(slice_uri, cursor, limit) 205 - .await? 134 + if let Some(in_values) = &condition.in_values { 135 + let str_values: Vec<String> = in_values 136 + .iter() 137 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 138 + .collect(); 139 + sqlx_query = sqlx_query.bind(str_values); 140 + } 141 + if let Some(contains_value) = &condition.contains { 142 + sqlx_query = sqlx_query.bind(contains_value); 143 + } 206 144 } 207 - } else { 208 - self.query_actors_with_cursor(slice_uri, cursor, limit) 209 - .await? 210 145 } 211 - } else { 212 - self.query_actors_with_cursor(slice_uri, cursor, limit) 213 - .await? 214 - }; 146 + } 215 147 216 - let cursor = if records.is_empty() { 217 - None 148 + // Bind cursor parameter 149 + if let Some(cursor_did) = cursor { 150 + sqlx_query = sqlx_query.bind(cursor_did); 151 + } 152 + 153 + // Bind limit 154 + sqlx_query = sqlx_query.bind(limit as i64); 155 + 156 + let records = sqlx_query.fetch_all(&self.pool).await?; 157 + 158 + let cursor = if records.len() < limit as usize { 159 + None // Last page - no more results 218 160 } else { 219 161 records.last().map(|actor| actor.did.clone()) 220 162 }; ··· 222 164 Ok((records, cursor)) 223 165 } 224 166 225 - /// Internal helper for basic actor queries with cursor pagination. 226 - async fn query_actors_with_cursor( 227 - &self, 228 - slice_uri: &str, 229 - cursor: Option<&str>, 230 - limit: i32, 231 - ) -> Result<Vec<Actor>, DatabaseError> { 232 - match cursor { 233 - Some(cursor_did) => sqlx::query_as!( 234 - Actor, 235 - r#" 236 - SELECT did, handle, slice_uri, indexed_at 237 - FROM actor 238 - WHERE slice_uri = $1 AND did > $2 239 - ORDER BY did ASC 240 - LIMIT $3 241 - "#, 242 - slice_uri, 243 - cursor_did, 244 - limit as i64 245 - ) 246 - .fetch_all(&self.pool) 247 - .await 248 - .map_err(DatabaseError::from), 249 - None => sqlx::query_as!( 250 - Actor, 251 - r#" 252 - SELECT did, handle, slice_uri, indexed_at 253 - FROM actor 254 - WHERE slice_uri = $1 255 - ORDER BY did ASC 256 - LIMIT $2 257 - "#, 258 - slice_uri, 259 - limit as i64 260 - ) 261 - .fetch_all(&self.pool) 262 - .await 263 - .map_err(DatabaseError::from), 264 - } 265 - } 266 167 267 168 /// Gets all actors across all slices. 268 169 /// ··· 324 225 Ok(result.rows_affected()) 325 226 } 326 227 } 228 + 229 + /// Builds WHERE conditions specifically for actor queries. 230 + /// 231 + /// Unlike the general query builder, this handles actor table columns directly 232 + /// rather than treating them as JSON paths. 233 + fn build_actor_where_conditions( 234 + where_clause: Option<&WhereClause>, 235 + param_count: &mut usize, 236 + ) -> (Vec<String>, Vec<String>) { 237 + let mut where_clauses = Vec::new(); 238 + let mut or_clauses = Vec::new(); 239 + 240 + if let Some(clause) = where_clause { 241 + for (field, condition) in &clause.conditions { 242 + let field_clause = build_actor_single_condition(field, condition, param_count); 243 + if !field_clause.is_empty() { 244 + where_clauses.push(field_clause); 245 + } 246 + } 247 + 248 + if let Some(or_conditions) = &clause.or_conditions { 249 + for (field, condition) in or_conditions { 250 + let field_clause = build_actor_single_condition(field, condition, param_count); 251 + if !field_clause.is_empty() { 252 + or_clauses.push(field_clause); 253 + } 254 + } 255 + } 256 + } 257 + 258 + (where_clauses, or_clauses) 259 + } 260 + 261 + /// Builds a single SQL condition clause for actor fields. 262 + fn build_actor_single_condition( 263 + field: &str, 264 + condition: &WhereCondition, 265 + param_count: &mut usize, 266 + ) -> String { 267 + if let Some(_eq_value) = &condition.eq { 268 + let clause = format!("{} = ${}", field, param_count); 269 + *param_count += 1; 270 + clause 271 + } else if let Some(_in_values) = &condition.in_values { 272 + let clause = format!("{} = ANY(${})", field, param_count); 273 + *param_count += 1; 274 + clause 275 + } else if let Some(_contains_value) = &condition.contains { 276 + let clause = format!("{} ILIKE '%' || ${} || '%'", field, param_count); 277 + *param_count += 1; 278 + clause 279 + } else { 280 + String::new() 281 + } 282 + }
+3 -2
api/src/database/records.rs
··· 342 342 343 343 let records = query_builder.fetch_all(&self.pool).await?; 344 344 345 - let cursor = if records.is_empty() { 346 - None 345 + // Only return cursor if we got a full page, indicating there might be more 346 + let cursor = if records.len() < limit as usize { 347 + None // Last page - no more results 347 348 } else { 348 349 records 349 350 .last()
+4 -4
api/src/xrpc/network/slices/slice/get_actors.rs
··· 1 1 use crate::{ 2 2 AppState, 3 + database::types::WhereClause, 3 4 errors::AppError, 4 - models::{Actor, WhereCondition}, 5 + models::Actor, 5 6 }; 6 7 use axum::{extract::State, response::Json}; 7 8 use serde::{Deserialize, Serialize}; 8 - use std::collections::HashMap; 9 9 10 10 #[derive(Debug, Deserialize)] 11 11 #[serde(rename_all = "camelCase")] ··· 14 14 pub limit: Option<i32>, 15 15 pub cursor: Option<String>, 16 16 #[serde(rename = "where")] 17 - pub where_conditions: Option<HashMap<String, WhereCondition>>, 17 + pub where_clause: Option<WhereClause>, 18 18 } 19 19 20 20 #[derive(Debug, Serialize)] ··· 34 34 &params.slice, 35 35 params.limit, 36 36 params.cursor.as_deref(), 37 - params.where_conditions.as_ref(), 37 + params.where_clause.as_ref(), 38 38 ) 39 39 .await 40 40 .map_err(|e| AppError::Internal(format!("Failed to fetch actors: {}", e)))?;
+5 -1
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-26 18:40:59 UTC 2 + // Generated at: 2025-09-28 00:41:14 UTC 3 3 // Lexicons: 40 4 4 5 5 /** ··· 984 984 indexedActorCount?: number; 985 985 /** Number of collections with indexed records */ 986 986 indexedCollectionCount?: number; 987 + /** Total number of waitlist requests for this slice */ 988 + waitlistRequestCount?: number; 989 + /** Total number of waitlist invites for this slice */ 990 + waitlistInviteCount?: number; 987 991 } 988 992 989 993 export interface NetworkSlicesSliceDefsSparklinePoint {
+116 -20
frontend/src/features/slices/waitlist/api.ts
··· 7 7 AppBskyActorProfile, 8 8 AtProtoClient, 9 9 } from "../../../client.ts"; 10 - import type { RecordResponse } from "@slices/client"; 10 + import type { RecordResponse, WhereCondition } from "@slices/client"; 11 11 import { recordBlobToCdnUrl } from "@slices/client"; 12 12 13 13 /** ··· 59 59 }; 60 60 } 61 61 62 + async function searchActorsAndProfiles( 63 + client: AtProtoClient, 64 + sliceUri: string, 65 + search: string 66 + ): Promise<string[]> { 67 + if (!search?.trim()) { 68 + return []; 69 + } 70 + 71 + const searchTerm = search.trim(); 72 + const matchingDids = new Set<string>(); 73 + 74 + try { 75 + // Search actors by handle or DID 76 + const actorsResponse = await client.network.slices.slice.getActors({ 77 + slice: sliceUri, 78 + where: { 79 + $or: { 80 + handle: { contains: searchTerm }, 81 + did: { eq: searchTerm }, 82 + }, 83 + }, 84 + }); 85 + 86 + // Add matching actor DIDs 87 + actorsResponse.actors?.forEach((actor) => { 88 + matchingDids.add(actor.did); 89 + }); 90 + 91 + // Search Bluesky profiles by displayName or other fields 92 + const profilesResponse = await client.app.bsky.actor.profile.getRecords({ 93 + orWhere: { 94 + displayName: { contains: searchTerm }, 95 + }, 96 + }); 97 + 98 + // Add matching profile DIDs 99 + profilesResponse.records?.forEach((record) => { 100 + matchingDids.add(record.did); 101 + }); 102 + } catch (error) { 103 + console.error("Error searching actors and profiles:", error); 104 + } 105 + 106 + return Array.from(matchingDids); 107 + } 108 + 62 109 export async function getHydratedWaitlistRequests( 63 110 client: AtProtoClient, 64 - sliceUri: string 65 - ): Promise<NetworkSlicesWaitlistDefsRequestView[]> { 66 - // Fetch waitlist requests 111 + sliceUri: string, 112 + cursor?: string, 113 + limit: number = 20, 114 + search?: string 115 + ): Promise<{ 116 + records: NetworkSlicesWaitlistDefsRequestView[]; 117 + cursor?: string; 118 + }> { 119 + // Build where conditions with optional search 120 + const whereConditions: Record<string, WhereCondition> = { 121 + slice: { eq: sliceUri }, 122 + }; 123 + 124 + // Add search condition if provided 125 + if (search && search.trim()) { 126 + const matchingDids = await searchActorsAndProfiles( 127 + client, 128 + sliceUri, 129 + search 130 + ); 131 + if (matchingDids.length > 0) { 132 + whereConditions.did = { in: matchingDids }; 133 + } else { 134 + // If no matching DIDs found, return empty results 135 + return { records: [], cursor: undefined }; 136 + } 137 + } 138 + 67 139 const requestsResponse = 68 140 await client.network.slices.waitlist.request.getRecords({ 69 - where: { 70 - slice: { eq: sliceUri }, 71 - }, 141 + where: whereConditions, 72 142 sortBy: [{ field: "createdAt", direction: "desc" }], 73 - limit: 20, 143 + limit, 144 + cursor, 74 145 }); 75 146 76 147 if (!requestsResponse.records || requestsResponse.records.length === 0) { 77 - return []; 148 + return { records: [], cursor: undefined }; 78 149 } 79 150 80 151 // Get unique DIDs from requests ··· 124 195 console.error("Error fetching profiles:", error); 125 196 } 126 197 127 - // Transform to RequestView format with profiles 128 - return requestsResponse.records.map((record) => 198 + const records = requestsResponse.records.map((record) => 129 199 requestToView(record, profilesMap.get(record.did)) 130 200 ); 201 + return { records, cursor: requestsResponse.cursor }; 131 202 } 132 203 133 204 export async function getHydratedWaitlistInvites( 134 205 client: AtProtoClient, 135 - sliceUri: string 136 - ): Promise<NetworkSlicesWaitlistDefsInviteView[]> { 137 - // Fetch waitlist invites 206 + sliceUri: string, 207 + cursor?: string, 208 + limit: number = 20, 209 + search?: string 210 + ): Promise<{ 211 + records: NetworkSlicesWaitlistDefsInviteView[]; 212 + cursor?: string; 213 + }> { 214 + // Build where conditions with optional search 215 + const whereConditions: Record<string, WhereCondition> = { 216 + slice: { eq: sliceUri }, 217 + }; 218 + 219 + // Add search condition if provided 220 + if (search && search.trim()) { 221 + const matchingDids = await searchActorsAndProfiles( 222 + client, 223 + sliceUri, 224 + search 225 + ); 226 + if (matchingDids.length > 0) { 227 + whereConditions.did = { in: matchingDids }; 228 + } else { 229 + // If no matching DIDs found, return empty results 230 + return { records: [], cursor: undefined }; 231 + } 232 + } 233 + 138 234 const invitesResponse = 139 235 await client.network.slices.waitlist.invite.getRecords({ 140 - where: { 141 - slice: { eq: sliceUri }, 142 - }, 236 + where: whereConditions, 143 237 sortBy: [{ field: "createdAt", direction: "desc" }], 238 + limit, 239 + cursor, 144 240 }); 145 241 146 242 if (!invitesResponse.records || invitesResponse.records.length === 0) { 147 - return []; 243 + return { records: [], cursor: undefined }; 148 244 } 149 245 150 246 // Get unique DIDs from invites ··· 194 290 console.error("Error fetching profiles:", error); 195 291 } 196 292 197 - // Transform to InviteView format with profiles 198 - return invitesResponse.records.map((record) => 293 + const records = invitesResponse.records.map((record) => 199 294 inviteToView(record, profilesMap.get(record.value.did)) 200 295 ); 296 + return { records, cursor: invitesResponse.cursor }; 201 297 } 202 298 203 299 export async function createInviteFromRequest(
+192 -8
frontend/src/features/slices/waitlist/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { withAuth } from "../../../routes/middleware.ts"; 3 3 import { withSliceAccess } from "../../../routes/slice-middleware.ts"; 4 - import { extractSliceParams, buildSliceUrlFromView } from "../../../utils/slice-params.ts"; 4 + import { 5 + extractSliceParams, 6 + buildSliceUrlFromView, 7 + } from "../../../utils/slice-params.ts"; 5 8 import { getSliceClient } from "../../../utils/client.ts"; 6 9 import { getRkeyFromUri, buildSliceUri } from "../../../utils/at-uri.ts"; 7 10 import { renderHTML } from "../../../utils/render.tsx"; 8 11 import { hxRedirect } from "../../../utils/htmx.ts"; 9 12 import { SliceWaitlistPage } from "./templates/SliceWaitlistPage.tsx"; 10 13 import { CreateInviteModal } from "./templates/fragments/CreateInviteModal.tsx"; 14 + import { WaitlistRequestsList } from "./templates/fragments/WaitlistRequestsList.tsx"; 15 + import { WaitlistInvitesList } from "./templates/fragments/WaitlistInvitesList.tsx"; 11 16 import type { 12 17 NetworkSlicesWaitlistDefsRequestView, 13 18 NetworkSlicesWaitlistDefsInviteView, ··· 57 62 sliceParams.sliceId 58 63 ); 59 64 65 + const currentCursor = url.searchParams.get("cursor") || undefined; 66 + const searchQuery = url.searchParams.get("search") || ""; 67 + const limit = 20; 68 + 60 69 let requests: NetworkSlicesWaitlistDefsRequestView[] = []; 61 70 let invites: NetworkSlicesWaitlistDefsInviteView[] = []; 71 + let requestsNextCursor: string | undefined; 72 + let invitesNextCursor: string | undefined; 62 73 63 74 try { 64 - // Fetch hydrated requests with profile information 65 - requests = await getHydratedWaitlistRequests(sliceClient, sliceUri); 66 - 67 - // Fetch hydrated invites with profile information 68 - invites = await getHydratedWaitlistInvites(sliceClient, sliceUri); 75 + if (activeTab === "requests") { 76 + const requestsData = await getHydratedWaitlistRequests( 77 + sliceClient, 78 + sliceUri, 79 + currentCursor, 80 + limit, 81 + searchQuery 82 + ); 83 + requests = requestsData.records; 84 + requestsNextCursor = requestsData.cursor; 85 + } else { 86 + const invitesData = await getHydratedWaitlistInvites( 87 + sliceClient, 88 + sliceUri, 89 + currentCursor, 90 + limit, 91 + searchQuery 92 + ); 93 + invites = invitesData.records; 94 + invitesNextCursor = invitesData.cursor; 95 + } 69 96 } catch (error) { 70 97 console.error("Error fetching waitlist data:", error); 71 - // Continue with empty arrays if fetch fails 98 + } 99 + 100 + // Check if this is an HTMX request targeting a specific container (search) 101 + const hxTarget = req.headers.get("hx-target"); 102 + const isContainerTargeted = hxTarget && (hxTarget.includes("-container")); 103 + 104 + if (isContainerTargeted) { 105 + // Return just the container content for search updates 106 + if (activeTab === "requests") { 107 + // Get invites for the requests list (to show "Already invited" status) 108 + const invitesData = await getHydratedWaitlistInvites( 109 + sliceClient, 110 + sliceUri, 111 + undefined, 112 + 1000 113 + ); 114 + const allInvites = invitesData.records; 115 + 116 + return renderHTML( 117 + <WaitlistRequestsList 118 + requests={requests} 119 + invites={allInvites} 120 + slice={context.sliceContext!.slice!} 121 + sliceId={sliceParams.sliceId} 122 + nextCursor={requestsNextCursor} 123 + activeTab={activeTab} 124 + searchQuery={searchQuery} 125 + showWrapper 126 + /> 127 + ); 128 + } else { 129 + return renderHTML( 130 + <WaitlistInvitesList 131 + invites={invites} 132 + slice={context.sliceContext!.slice!} 133 + sliceId={sliceParams.sliceId} 134 + nextCursor={invitesNextCursor} 135 + activeTab={activeTab} 136 + searchQuery={searchQuery} 137 + showWrapper 138 + /> 139 + ); 140 + } 72 141 } 73 142 74 143 return renderHTML( ··· 80 149 currentUser={authContext.currentUser} 81 150 hasSliceAccess={context.sliceContext?.hasAccess} 82 151 activeTab={activeTab} 152 + searchQuery={searchQuery} 153 + requestsNextCursor={requestsNextCursor} 154 + invitesNextCursor={invitesNextCursor} 83 155 /> 84 156 ); 85 157 } ··· 246 318 } 247 319 } 248 320 321 + async function handleWaitlistLoadMore( 322 + req: Request, 323 + params?: URLPatternResult 324 + ): Promise<Response> { 325 + const authContext = await withAuth(req); 326 + const sliceParams = extractSliceParams(params); 327 + 328 + if (!sliceParams) { 329 + return new Response("Invalid slice parameters", { status: 400 }); 330 + } 331 + 332 + const context = await withSliceAccess( 333 + authContext, 334 + sliceParams.handle, 335 + sliceParams.sliceId 336 + ); 337 + 338 + if (!context.sliceContext?.slice || !context.sliceContext?.hasAccess) { 339 + return new Response("Slice not found or access denied", { status: 404 }); 340 + } 341 + 342 + const url = new URL(req.url); 343 + const activeTab = url.searchParams.get("tab") || "requests"; 344 + const currentCursor = url.searchParams.get("cursor") || undefined; 345 + const searchQuery = url.searchParams.get("search") || ""; 346 + const limit = 20; 347 + 348 + const sliceClient = getSliceClient( 349 + authContext, 350 + sliceParams.sliceId, 351 + context.sliceContext.profileDid 352 + ); 353 + 354 + const sliceUri = buildSliceUri( 355 + context.sliceContext.profileDid, 356 + sliceParams.sliceId 357 + ); 358 + 359 + try { 360 + if (activeTab === "requests") { 361 + const requestsData = await getHydratedWaitlistRequests( 362 + sliceClient, 363 + sliceUri, 364 + currentCursor, 365 + limit, 366 + searchQuery 367 + ); 368 + const requests = requestsData.records; 369 + const nextCursor = requestsData.cursor; 370 + 371 + // Get invites for the requests list (to show "Already invited" status) 372 + const invitesData = await getHydratedWaitlistInvites( 373 + sliceClient, 374 + sliceUri, 375 + undefined, 376 + 1000 377 + ); 378 + const invites = invitesData.records; 379 + 380 + // Return just the new records and Load More button (without wrapper) 381 + return renderHTML( 382 + <WaitlistRequestsList 383 + requests={requests} 384 + invites={invites} 385 + slice={context.sliceContext!.slice!} 386 + sliceId={sliceParams.sliceId} 387 + nextCursor={nextCursor} 388 + activeTab={activeTab} 389 + searchQuery={searchQuery} 390 + showWrapper={false} 391 + /> 392 + ); 393 + } else { 394 + const invitesData = await getHydratedWaitlistInvites( 395 + sliceClient, 396 + sliceUri, 397 + currentCursor, 398 + limit, 399 + searchQuery 400 + ); 401 + const invites = invitesData.records; 402 + const nextCursor = invitesData.cursor; 403 + 404 + // Return just the new records and Load More button (without wrapper) 405 + return renderHTML( 406 + <WaitlistInvitesList 407 + invites={invites} 408 + slice={context.sliceContext!.slice!} 409 + sliceId={sliceParams.sliceId} 410 + nextCursor={nextCursor} 411 + activeTab={activeTab} 412 + searchQuery={searchQuery} 413 + showWrapper={false} 414 + /> 415 + ); 416 + } 417 + } catch (error) { 418 + console.error("Error fetching waitlist data:", error); 419 + return new Response("Error loading more items", { status: 500 }); 420 + } 421 + } 422 + 249 423 async function handleCreateInviteFromRequest( 250 424 req: Request, 251 425 params?: URLPatternResult ··· 283 457 return new Response("DID is required", { status: 400 }); 284 458 } 285 459 286 - const sliceUri = buildSliceUri(context.sliceContext.profileDid, sliceParams.sliceId); 460 + const sliceUri = buildSliceUri( 461 + context.sliceContext.profileDid, 462 + sliceParams.sliceId 463 + ); 287 464 288 465 const inviteData: NetworkSlicesWaitlistInvite = { 289 466 did, ··· 315 492 pathname: "/profile/:handle/slice/:rkey/waitlist", 316 493 }), 317 494 handler: handleSliceWaitlistPage, 495 + }, 496 + { 497 + method: "GET", 498 + pattern: new URLPattern({ 499 + pathname: "/profile/:handle/slice/:rkey/waitlist/load-more", 500 + }), 501 + handler: handleWaitlistLoadMore, 318 502 }, 319 503 { 320 504 method: "GET",
+37 -13
frontend/src/features/slices/waitlist/templates/SliceWaitlistPage.tsx
··· 1 1 import { SlicePage } from "../../shared/fragments/SlicePage.tsx"; 2 2 import { WaitlistRequestsList } from "./fragments/WaitlistRequestsList.tsx"; 3 3 import { WaitlistInvitesList } from "./fragments/WaitlistInvitesList.tsx"; 4 + import { WaitlistSearch } from "./fragments/WaitlistSearch.tsx"; 4 5 import { Button } from "../../../../shared/fragments/Button.tsx"; 5 6 import { Tabs } from "../../../../shared/fragments/Tabs.tsx"; 6 7 import { buildSliceUrlFromView } from "../../../../utils/slice-params.ts"; ··· 19 20 currentUser?: AuthenticatedUser; 20 21 hasSliceAccess?: boolean; 21 22 activeTab?: string; 23 + searchQuery?: string; 24 + requestsNextCursor?: string; 25 + invitesNextCursor?: string; 22 26 } 23 27 24 28 export function SliceWaitlistPage({ ··· 29 33 currentUser, 30 34 hasSliceAccess, 31 35 activeTab = "requests", 36 + searchQuery = "", 37 + requestsNextCursor, 38 + invitesNextCursor, 32 39 }: SliceWaitlistPageProps) { 33 40 return ( 34 41 <SlicePage ··· 55 62 <Tabs.List> 56 63 <Tabs.Tab 57 64 active={activeTab === "requests"} 58 - count={requests.length} 65 + count={slice.waitlistRequestCount} 59 66 hxGet={buildSliceUrlFromView(slice, sliceId, "waitlist?tab=requests")} 60 67 hxTarget="body" 61 68 > ··· 63 70 </Tabs.Tab> 64 71 <Tabs.Tab 65 72 active={activeTab === "invites"} 66 - count={invites.length} 73 + count={slice.waitlistInviteCount} 67 74 hxGet={buildSliceUrlFromView(slice, sliceId, "waitlist?tab=invites")} 68 75 hxTarget="body" 69 76 > ··· 71 78 </Tabs.Tab> 72 79 </Tabs.List> 73 80 81 + <WaitlistSearch 82 + search={searchQuery} 83 + sliceId={sliceId} 84 + slice={slice} 85 + activeTab={activeTab} 86 + /> 87 + 74 88 <Tabs.Content active={activeTab === "requests"}> 75 - <WaitlistRequestsList 76 - requests={requests} 77 - invites={invites} 78 - slice={slice} 79 - sliceId={sliceId} 80 - /> 89 + <div id="requests-container"> 90 + <WaitlistRequestsList 91 + requests={requests} 92 + invites={invites} 93 + slice={slice} 94 + sliceId={sliceId} 95 + nextCursor={requestsNextCursor} 96 + activeTab={activeTab} 97 + searchQuery={searchQuery} 98 + /> 99 + </div> 81 100 </Tabs.Content> 82 101 83 102 <Tabs.Content active={activeTab === "invites"}> 84 - <WaitlistInvitesList 85 - invites={invites} 86 - slice={slice} 87 - sliceId={sliceId} 88 - /> 103 + <div id="invites-container"> 104 + <WaitlistInvitesList 105 + invites={invites} 106 + slice={slice} 107 + sliceId={sliceId} 108 + nextCursor={invitesNextCursor} 109 + activeTab={activeTab} 110 + searchQuery={searchQuery} 111 + /> 112 + </div> 89 113 </Tabs.Content> 90 114 </Tabs> 91 115 </div>
+27 -4
frontend/src/features/slices/waitlist/templates/fragments/WaitlistInvitesList.tsx
··· 3 3 import { Text } from "../../../../../shared/fragments/Text.tsx"; 4 4 import { EmptyState } from "../../../../../shared/fragments/EmptyState.tsx"; 5 5 import { ActorAvatar } from "../../../../../shared/fragments/ActorAvatar.tsx"; 6 + import { LoadMore } from "../../../../../shared/fragments/LoadMore.tsx"; 6 7 import { buildSliceUrlFromView } from "../../../../../utils/slice-params.ts"; 7 8 import { timeAgo } from "../../../../../utils/time.ts"; 8 9 import { UserCheck } from "lucide-preact"; ··· 15 16 invites: NetworkSlicesWaitlistDefsInviteView[]; 16 17 slice: NetworkSlicesSliceDefsSliceView; 17 18 sliceId: string; 19 + nextCursor?: string; 20 + activeTab: string; 21 + searchQuery?: string; 22 + showWrapper?: boolean; 18 23 } 19 24 20 25 export function WaitlistInvitesList({ 21 26 invites, 22 27 slice, 23 28 sliceId, 29 + nextCursor, 30 + activeTab, 31 + searchQuery = "", 32 + showWrapper = true, 24 33 }: WaitlistInvitesListProps) { 25 34 const isExpired = (invite: NetworkSlicesWaitlistDefsInviteView) => { 26 35 if (!invite.expiresAt) return false; ··· 38 47 ); 39 48 } 40 49 41 - return ( 42 - <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm"> 50 + const baseUrl = buildSliceUrlFromView(slice, sliceId, `waitlist?tab=${activeTab}`); 51 + const nextUrl = nextCursor ? `${baseUrl}&cursor=${nextCursor}${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}` : undefined; 52 + 53 + const content = ( 54 + <> 43 55 {invites.map((invite, index) => ( 44 - <ListItem key={`invite-${index}`}> 56 + <ListItem key={`invite-${index}`} data-record-item> 45 57 <div className="flex items-center justify-between w-full px-6 py-4"> 46 58 <div className="flex items-center gap-3 flex-1 min-w-0"> 47 59 <ActorAvatar ··· 88 100 </div> 89 101 </ListItem> 90 102 ))} 91 - </div> 103 + <LoadMore nextUrl={nextUrl} /> 104 + </> 92 105 ); 106 + 107 + if (showWrapper) { 108 + return ( 109 + <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm"> 110 + {content} 111 + </div> 112 + ); 113 + } 114 + 115 + return content; 93 116 }
+27 -4
frontend/src/features/slices/waitlist/templates/fragments/WaitlistRequestsList.tsx
··· 3 3 import { Text } from "../../../../../shared/fragments/Text.tsx"; 4 4 import { EmptyState } from "../../../../../shared/fragments/EmptyState.tsx"; 5 5 import { ActorAvatar } from "../../../../../shared/fragments/ActorAvatar.tsx"; 6 + import { LoadMore } from "../../../../../shared/fragments/LoadMore.tsx"; 6 7 import { Users } from "lucide-preact"; 7 8 import type { 8 9 NetworkSlicesWaitlistDefsRequestView, ··· 17 18 invites: NetworkSlicesWaitlistDefsInviteView[]; 18 19 slice: NetworkSlicesSliceDefsSliceView; 19 20 sliceId: string; 21 + nextCursor?: string; 22 + activeTab: string; 23 + searchQuery?: string; 24 + showWrapper?: boolean; 20 25 } 21 26 22 27 export function WaitlistRequestsList({ ··· 24 29 invites, 25 30 slice, 26 31 sliceId, 32 + nextCursor, 33 + activeTab, 34 + searchQuery = "", 35 + showWrapper = true, 27 36 }: WaitlistRequestsListProps) { 28 37 if (requests.length === 0) { 29 38 return ( ··· 36 45 ); 37 46 } 38 47 39 - return ( 40 - <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm"> 48 + const baseUrl = buildSliceUrlFromView(slice, sliceId, `waitlist?tab=${activeTab}`); 49 + const nextUrl = nextCursor ? `${baseUrl}&cursor=${nextCursor}${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}` : undefined; 50 + 51 + const content = ( 52 + <> 41 53 {requests.map((request, index) => { 42 54 // Check if this DID already has an invite 43 55 const requestDid = request.profile?.did || "unknown"; 44 56 const hasInvite = invites.some(invite => invite.did === requestDid); 45 57 46 58 return ( 47 - <ListItem key={`request-${index}`}> 59 + <ListItem key={`request-${index}`} data-record-item> 48 60 <div className="flex items-center justify-between w-full px-6 py-4"> 49 61 <div className="flex items-center gap-3 flex-1 min-w-0"> 50 62 <ActorAvatar ··· 87 99 </ListItem> 88 100 ); 89 101 })} 90 - </div> 102 + <LoadMore nextUrl={nextUrl} /> 103 + </> 91 104 ); 105 + 106 + if (showWrapper) { 107 + return ( 108 + <div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-sm"> 109 + {content} 110 + </div> 111 + ); 112 + } 113 + 114 + return content; 92 115 }
+38
frontend/src/features/slices/waitlist/templates/fragments/WaitlistSearch.tsx
··· 1 + import { Input } from "../../../../../shared/fragments/Input.tsx"; 2 + import { buildSliceUrlFromView } from "../../../../../utils/slice-params.ts"; 3 + import type { NetworkSlicesSliceDefsSliceView } from "../../../../../client.ts"; 4 + 5 + interface WaitlistSearchProps { 6 + search: string; 7 + sliceId: string; 8 + slice: NetworkSlicesSliceDefsSliceView; 9 + activeTab: string; 10 + } 11 + 12 + export function WaitlistSearch({ 13 + search, 14 + sliceId, 15 + slice, 16 + activeTab, 17 + }: WaitlistSearchProps) { 18 + const waitlistUrl = buildSliceUrlFromView( 19 + slice, 20 + sliceId, 21 + `waitlist?tab=${activeTab}` 22 + ); 23 + 24 + return ( 25 + <div className="pt-4 pb-2"> 26 + <Input 27 + type="text" 28 + name="search" 29 + value={search} 30 + placeholder="Search by DID, handle, or display name" 31 + hx-get={waitlistUrl} 32 + hx-trigger="input changed delay:300ms, search" 33 + hx-target={`#${activeTab}-container`} 34 + hx-swap="innerHTML" 35 + /> 36 + </div> 37 + ); 38 + }
+16 -10
frontend/src/features/waitlist/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 + import type { NetworkSlicesWaitlistDefsRequestView } from "../../client.ts"; 2 3 import { withAuth } from "../../routes/middleware.ts"; 3 4 import { renderHTML } from "../../utils/render.tsx"; 4 5 import { WaitlistPage } from "./templates/WaitlistPage.tsx"; ··· 15 16 const error = url.searchParams.get("error"); 16 17 17 18 // Fetch recent waitlist requests to show avatars for social proof 18 - let recentRequests; 19 + let recentRequests: NetworkSlicesWaitlistDefsRequestView[] = []; 19 20 let totalWaitlistCount = 0; 20 21 if (SLICE_URI) { 21 22 try { 22 23 // Get total count of waitlist requests 23 - const countResponse = await publicClient.network.slices.waitlist.request.countRecords({ 24 - where: { 25 - slice: { eq: SLICE_URI }, 26 - }, 27 - }); 24 + const countResponse = 25 + await publicClient.network.slices.waitlist.request.countRecords({ 26 + where: { 27 + slice: { eq: SLICE_URI }, 28 + }, 29 + }); 28 30 totalWaitlistCount = countResponse.count; 29 31 30 - recentRequests = await getHydratedWaitlistRequests(publicClient, SLICE_URI); 31 - // Limit to most recent 10 and reverse to show newest first 32 - recentRequests = recentRequests.slice(0, 10); 32 + const hydratedResponse = await getHydratedWaitlistRequests( 33 + publicClient, 34 + SLICE_URI, 35 + undefined, 36 + 10 37 + ); 38 + recentRequests = hydratedResponse.records; 33 39 } catch (error) { 34 40 console.error("Failed to fetch recent waitlist requests:", error); 35 41 // Continue without recent requests if fetch fails ··· 54 60 pattern: new URLPattern({ pathname: "/waitlist" }), 55 61 handler: handleWaitlistPage, 56 62 }, 57 - ]; 63 + ];
+16 -9
frontend/src/features/waitlist/templates/fragments/WaitlistForm.tsx
··· 12 12 totalWaitlistCount?: number; 13 13 } 14 14 15 - export function WaitlistForm({ error, recentRequests, totalWaitlistCount }: WaitlistFormProps) { 15 + export function WaitlistForm({ 16 + error, 17 + recentRequests, 18 + totalWaitlistCount, 19 + }: WaitlistFormProps) { 16 20 const getErrorMessage = (error: string) => { 17 21 switch (error) { 18 22 case "oauth_not_configured": ··· 36 40 37 41 return ( 38 42 <Card padding="md"> 39 - {recentRequests && recentRequests.length > 0 && totalWaitlistCount && totalWaitlistCount > 0 && ( 40 - <div className="mb-6 text-center"> 41 - <Text as="p" size="sm" variant="muted" className="mb-3"> 42 - Join {totalWaitlistCount.toLocaleString()} others who are waiting 43 - </Text> 44 - <AvatarStack requests={recentRequests} maxDisplay={20} size={24} /> 45 - </div> 46 - )} 43 + {recentRequests && 44 + recentRequests.length > 0 && 45 + totalWaitlistCount && 46 + totalWaitlistCount > 0 && ( 47 + <div className="mb-6 text-center"> 48 + <Text as="p" size="sm" variant="muted" className="mb-3"> 49 + Join {totalWaitlistCount.toLocaleString()} others who are waiting 50 + </Text> 51 + <AvatarStack requests={recentRequests} maxDisplay={20} size={24} /> 52 + </div> 53 + )} 47 54 48 55 <form action="/auth/waitlist/initiate" method="POST"> 49 56 <div className="space-y-6">
+17 -5
frontend/src/lib/api.ts
··· 75 75 const creatorProfile = await getSliceActor(client, sliceRecord.did); 76 76 if (!creatorProfile) return null; 77 77 78 - const sparklinesMap = await fetchSparklinesForSlices(client, [uri]); 79 - const sparklineData = sparklinesMap[uri]; 78 + const [sparklinesMap, stats, requestCount, inviteCount] = await Promise.all([ 79 + fetchSparklinesForSlices(client, [uri]), 80 + fetchStatsForSlice(client, uri), 81 + client.network.slices.waitlist.request.countRecords({ 82 + where: { slice: { eq: uri } }, 83 + }).then(r => r.count).catch(() => 0), 84 + client.network.slices.waitlist.invite.countRecords({ 85 + where: { slice: { eq: uri } }, 86 + }).then(r => r.count).catch(() => 0), 87 + ]); 80 88 81 - const stats = await fetchStatsForSlice(client, uri); 89 + const sparklineData = sparklinesMap[uri]; 82 90 83 - return sliceToView(sliceRecord, creatorProfile, sparklineData, stats); 91 + return sliceToView(sliceRecord, creatorProfile, sparklineData, stats, requestCount, inviteCount); 84 92 } catch (error) { 85 93 console.error("Failed to get slice:", error); 86 94 return null; ··· 120 128 sliceRecord: RecordResponse<NetworkSlicesSlice>, 121 129 creator: NetworkSlicesActorDefsProfileViewBasic, 122 130 sparkline?: NetworkSlicesSliceDefsSparklinePoint[], 123 - stats?: NetworkSlicesSliceStatsOutput | null 131 + stats?: NetworkSlicesSliceStatsOutput | null, 132 + waitlistRequestCount?: number, 133 + waitlistInviteCount?: number 124 134 ): NetworkSlicesSliceDefsSliceView { 125 135 return { 126 136 uri: sliceRecord.uri, ··· 133 143 indexedRecordCount: stats?.totalRecords || 0, 134 144 indexedActorCount: stats?.totalActors || 0, 135 145 indexedCollectionCount: stats?.collectionStats.length || 0, 146 + waitlistRequestCount: waitlistRequestCount || 0, 147 + waitlistInviteCount: waitlistInviteCount || 0, 136 148 }; 137 149 } 138 150
+29
frontend/src/shared/fragments/LoadMore.tsx
··· 1 + import type { JSX } from "preact"; 2 + import { Button } from "./Button.tsx"; 3 + 4 + interface LoadMoreProps { 5 + nextUrl?: string; 6 + loading?: boolean; 7 + } 8 + 9 + export function LoadMore({ nextUrl, loading = false }: LoadMoreProps): JSX.Element | null { 10 + if (!nextUrl) { 11 + return null; 12 + } 13 + 14 + return ( 15 + <div className="load-more-container text-center py-4 border-t border-zinc-200 dark:border-zinc-700"> 16 + <Button 17 + variant="outline" 18 + size="md" 19 + disabled={loading} 20 + hx-get={nextUrl.replace("/waitlist?", "/waitlist/load-more?")} 21 + hx-target="closest .load-more-container" 22 + hx-swap="outerHTML" 23 + hx-on="htmx:beforeRequest: this.disabled = true; this.textContent = 'Loading...';" 24 + > 25 + {loading ? "Loading..." : "Load More"} 26 + </Button> 27 + </div> 28 + ); 29 + }
+1 -1
frontend/src/shared/fragments/Tabs.tsx
··· 127 127 }: TabsContentProps): JSX.Element | null { 128 128 if (!active) return null; 129 129 130 - return <div className={cn("py-6", className)}>{children}</div>; 130 + return <div className={cn("py-2", className)}>{children}</div>; 131 131 };
+8
lexicons/network/slices/slice/defs.json
··· 50 50 "indexedCollectionCount": { 51 51 "type": "integer", 52 52 "description": "Number of collections with indexed records" 53 + }, 54 + "waitlistRequestCount": { 55 + "type": "integer", 56 + "description": "Total number of waitlist requests for this slice" 57 + }, 58 + "waitlistInviteCount": { 59 + "type": "integer", 60 + "description": "Total number of waitlist invites for this slice" 53 61 } 54 62 } 55 63 },
+5 -1
packages/cli/src/generated_client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-26 18:21:58 UTC 2 + // Generated at: 2025-09-28 00:41:51 UTC 3 3 // Lexicons: 40 4 4 5 5 /** ··· 984 984 indexedActorCount?: number; 985 985 /** Number of collections with indexed records */ 986 986 indexedCollectionCount?: number; 987 + /** Total number of waitlist requests for this slice */ 988 + waitlistRequestCount?: number; 989 + /** Total number of waitlist invites for this slice */ 990 + waitlistInviteCount?: number; 987 991 } 988 992 989 993 export interface NetworkSlicesSliceDefsSparklinePoint {