Highly ambitious ATProtocol AppView service and sdks
0
fork

Configure Feed

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

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 {