The smokesignal.events web application

feature: location suggestions include recent smoke signal event locations and source logos

+271 -93
+8 -5
src/http/handle_location_suggestions.rs
··· 19 19 pub suggestions: OrderSet<LocationSuggestion>, 20 20 } 21 21 22 - /// Handler to fetch beaconbits location suggestions for the authenticated user 22 + /// Handler to fetch location suggestions for the authenticated user 23 23 /// 24 24 /// GET /event/location-suggestions 25 25 /// 26 - /// Returns the user's 5 most recent beaconbits locations (bookmarks and beacons) 27 - /// that can be used to pre-populate event location fields. 26 + /// Returns the user's recent locations from multiple sources: 27 + /// - Up to 100 most recent beaconbits/dropanchor locations 28 + /// - Locations from up to 100 most recent events created by the user 29 + /// 30 + /// Results are deduplicated and can be used to pre-populate event location fields. 28 31 pub(crate) async fn handle_location_suggestions( 29 32 State(web_context): State<WebContext>, 30 33 Cached(auth): Cached<Auth>, ··· 43 46 } 44 47 }; 45 48 46 - // Fetch location suggestions (limit to 5 most recent) 49 + // Fetch location suggestions from all sources 47 50 let suggestions = 48 - atproto_record_get_location_suggestions(&web_context.pool, &current_handle.did, 5) 51 + atproto_record_get_location_suggestions(&web_context.pool, &current_handle.did) 49 52 .await 50 53 .unwrap_or_else(|err| { 51 54 tracing::warn!(
+169 -67
src/storage/atproto_record.rs
··· 130 130 131 131 /// Query location records for a user, returning most recent locations. 132 132 /// 133 - /// Queries `app.beaconbits.bookmark.item`, `app.beaconbits.beacon`, and `app.dropanchor.checkin` 134 - /// collections, orders by indexed_at descending, and limits to `count` results. Duplicates are 135 - /// removed based on location fields (name, street, locality, region, postal_code, country). 133 + /// Queries locations from multiple sources: 134 + /// - Up to 100 most recent `app.beaconbits.bookmark.item`, `app.beaconbits.beacon`, `app.dropanchor.checkin` from atproto_records 135 + /// - Locations from up to 100 most recent events created by the user 136 + /// 137 + /// Results are ordered by timestamp descending and deduplicated based on location fields 138 + /// (name, street, locality, region, postal_code, country, latitude, longitude). 136 139 pub async fn atproto_record_get_location_suggestions( 137 140 pool: &StoragePool, 138 141 did: &str, 139 - count: i64, 140 142 ) -> Result<OrderSet<LocationSuggestion>, StorageError> { 141 - let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = sqlx::query_as( 142 - r#" 143 - SELECT aturi, record 144 - FROM atproto_records 145 - WHERE did = $1 146 - AND collection IN ( 147 - 'app.beaconbits.bookmark.item', 148 - 'app.beaconbits.beacon', 149 - 'app.dropanchor.checkin' 150 - ) 151 - ORDER BY indexed_at DESC 152 - LIMIT $2 153 - "#, 154 - ) 155 - .bind(did) 156 - .bind(count) 157 - .fetch_all(pool) 158 - .await 159 - .map_err(StorageError::UnableToExecuteQuery)?; 143 + // Fetch up to 100 records from each source 144 + let fetch_limit: i64 = 100; 160 145 161 - let suggestions: OrderSet<LocationSuggestion> = rows 162 - .into_iter() 163 - .map(|(aturi, record)| { 164 - let r = &record.0; 165 - // Try addressDetails (beaconbits) first, then address (dropanchor) 166 - let address = r.get("addressDetails").or_else(|| r.get("address")); 167 - // Try location (beaconbits) first, then geo (dropanchor) 168 - let geo = r.get("location").or_else(|| r.get("geo")); 146 + // Query 1: beaconbits/dropanchor from atproto_records 147 + let atproto_rows: Vec<(String, DateTime<Utc>, sqlx::types::Json<serde_json::Value>)> = 148 + sqlx::query_as( 149 + r#" 150 + SELECT aturi, indexed_at, record 151 + FROM atproto_records 152 + WHERE did = $1 153 + AND collection IN ( 154 + 'app.beaconbits.bookmark.item', 155 + 'app.beaconbits.beacon', 156 + 'app.dropanchor.checkin' 157 + ) 158 + ORDER BY indexed_at DESC 159 + LIMIT $2 160 + "#, 161 + ) 162 + .bind(did) 163 + .bind(fetch_limit) 164 + .fetch_all(pool) 165 + .await 166 + .map_err(StorageError::UnableToExecuteQuery)?; 169 167 170 - LocationSuggestion { 171 - source: Some(aturi), 172 - name: address 173 - .and_then(|a| a.get("name")) 174 - .and_then(|v| v.as_str()) 175 - .map(String::from), 176 - street: address 177 - .and_then(|a| a.get("street")) 178 - .and_then(|v| v.as_str()) 179 - .map(String::from), 180 - locality: address 181 - .and_then(|a| a.get("locality")) 182 - .and_then(|v| v.as_str()) 183 - .map(String::from), 184 - region: address 185 - .and_then(|a| a.get("region")) 186 - .and_then(|v| v.as_str()) 187 - .map(String::from), 188 - postal_code: address 189 - .and_then(|a| a.get("postalCode")) 190 - .and_then(|v| v.as_str()) 191 - .map(String::from), 192 - country: address 193 - .and_then(|a| a.get("country")) 194 - .and_then(|v| v.as_str()) 195 - .map(String::from), 196 - latitude: geo 197 - .and_then(|g| g.get("latitude")) 198 - .and_then(|v| v.as_str()) 199 - .map(String::from), 200 - longitude: geo 201 - .and_then(|g| g.get("longitude")) 202 - .and_then(|v| v.as_str()) 203 - .map(String::from), 204 - } 205 - }) 168 + // Query 2: events with locations created by this user 169 + let event_rows: Vec<(String, Option<DateTime<Utc>>, sqlx::types::Json<serde_json::Value>)> = 170 + sqlx::query_as( 171 + r#" 172 + SELECT aturi, updated_at, record 173 + FROM events 174 + WHERE did = $1 175 + AND json_array_length(COALESCE(record->'locations', '[]'::json)) > 0 176 + ORDER BY updated_at DESC NULLS LAST 177 + LIMIT $2 178 + "#, 179 + ) 180 + .bind(did) 181 + .bind(fetch_limit) 182 + .fetch_all(pool) 183 + .await 184 + .map_err(StorageError::UnableToExecuteQuery)?; 185 + 186 + // Collect all suggestions with timestamps for sorting 187 + let mut suggestions_with_time: Vec<(DateTime<Utc>, LocationSuggestion)> = Vec::new(); 188 + 189 + // Process atproto records (beaconbits/dropanchor) 190 + for (aturi, indexed_at, record) in atproto_rows { 191 + let r = &record.0; 192 + // Try addressDetails (beaconbits) first, then address (dropanchor) 193 + let address = r.get("addressDetails").or_else(|| r.get("address")); 194 + // Try location (beaconbits) first, then geo (dropanchor) 195 + let geo = r.get("location").or_else(|| r.get("geo")); 196 + 197 + let suggestion = LocationSuggestion { 198 + source: Some(aturi), 199 + name: address 200 + .and_then(|a| a.get("name")) 201 + .and_then(|v| v.as_str()) 202 + .map(String::from), 203 + street: address 204 + .and_then(|a| a.get("street")) 205 + .and_then(|v| v.as_str()) 206 + .map(String::from), 207 + locality: address 208 + .and_then(|a| a.get("locality")) 209 + .and_then(|v| v.as_str()) 210 + .map(String::from), 211 + region: address 212 + .and_then(|a| a.get("region")) 213 + .and_then(|v| v.as_str()) 214 + .map(String::from), 215 + postal_code: address 216 + .and_then(|a| a.get("postalCode")) 217 + .and_then(|v| v.as_str()) 218 + .map(String::from), 219 + country: address 220 + .and_then(|a| a.get("country")) 221 + .and_then(|v| v.as_str()) 222 + .map(String::from), 223 + latitude: geo 224 + .and_then(|g| g.get("latitude")) 225 + .and_then(|v| v.as_str()) 226 + .map(String::from), 227 + longitude: geo 228 + .and_then(|g| g.get("longitude")) 229 + .and_then(|v| v.as_str()) 230 + .map(String::from), 231 + }; 232 + suggestions_with_time.push((indexed_at, suggestion)); 233 + } 234 + 235 + // Process event locations 236 + for (aturi, updated_at, record) in event_rows { 237 + let timestamp = updated_at.unwrap_or_else(Utc::now); 238 + let locations = extract_locations_from_event(&aturi, &record.0); 239 + for suggestion in locations { 240 + suggestions_with_time.push((timestamp, suggestion)); 241 + } 242 + } 243 + 244 + // Sort by timestamp descending (most recent first) 245 + suggestions_with_time.sort_by(|a, b| b.0.cmp(&a.0)); 246 + 247 + // Collect into OrderSet (deduplicates based on location fields) 248 + let suggestions: OrderSet<LocationSuggestion> = suggestions_with_time 249 + .into_iter() 250 + .map(|(_, s)| s) 206 251 .collect(); 207 252 208 253 Ok(suggestions) 209 254 } 255 + 256 + /// Extract locations from an event record's locations array. 257 + /// 258 + /// Handles both address and geo location types based on the `$type` field. 259 + fn extract_locations_from_event(aturi: &str, record: &serde_json::Value) -> Vec<LocationSuggestion> { 260 + let Some(locations) = record.get("locations").and_then(|l| l.as_array()) else { 261 + return vec![]; 262 + }; 263 + 264 + locations 265 + .iter() 266 + .filter_map(|loc| { 267 + let loc_type = loc.get("$type").and_then(|t| t.as_str())?; 268 + 269 + match loc_type { 270 + "community.lexicon.location.address" => Some(LocationSuggestion { 271 + source: Some(aturi.to_string()), 272 + name: loc.get("name").and_then(|v| v.as_str()).map(String::from), 273 + street: loc.get("street").and_then(|v| v.as_str()).map(String::from), 274 + locality: loc 275 + .get("locality") 276 + .and_then(|v| v.as_str()) 277 + .map(String::from), 278 + region: loc.get("region").and_then(|v| v.as_str()).map(String::from), 279 + postal_code: loc 280 + .get("postalCode") 281 + .and_then(|v| v.as_str()) 282 + .map(String::from), 283 + country: loc 284 + .get("country") 285 + .and_then(|v| v.as_str()) 286 + .map(String::from), 287 + latitude: None, 288 + longitude: None, 289 + }), 290 + "community.lexicon.location.geo" => Some(LocationSuggestion { 291 + source: Some(aturi.to_string()), 292 + name: loc.get("name").and_then(|v| v.as_str()).map(String::from), 293 + street: None, 294 + locality: None, 295 + region: None, 296 + postal_code: None, 297 + country: None, 298 + latitude: loc 299 + .get("latitude") 300 + .and_then(|v| v.as_str()) 301 + .map(String::from), 302 + longitude: loc 303 + .get("longitude") 304 + .and_then(|v| v.as_str()) 305 + .map(String::from), 306 + }), 307 + _ => None, 308 + } 309 + }) 310 + .collect() 311 + }
+1
static/logo-beaconbits.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="705" height="704" fill="none" viewBox="0 0 705 704" class="size-10 flex-shrink-0"><path fill="#E24630" fill-rule="evenodd" d="M0 350.996c.04-10.352.258-66.426 24.46-127.069 88.188-220.98 353.658-290.2 538.505-154.374 104.17 76.546 160.762 211.775 135.265 349.385-24.074 129.929-113.769 208.359-169.863 239.601-169.703 94.514-380.779 35.564-476.113-120.317C-.7 451.638.005 375.87 0 350.996Z" clip-rule="evenodd"></path><path fill="#F7F7F7" fill-rule="evenodd" d="M353.992 344.986c5.231 50.808 61.671 74.893 33.865 156.094-7.743 22.612-38.143 75.581-60.212 67.864-16.179-5.657-18.418-20.395-9.568-31.61 45.692-57.901 40.89-89.345 11.937-136.575-57.392-93.622 22.565-125.319 22.455-187.758-.073-41.005-41.264-49.577-24.442-71.699 4.208-5.534 16.876-14.627 35.359 1.969 69.336 62.256 10.012 134.209-.504 156.844-11.108 23.909-8.889 43.083-8.89 44.871ZM243.992 344.986c5.231 50.808 61.671 74.893 33.865 156.094-7.743 22.612-38.143 75.581-60.212 67.864-16.179-5.657-18.418-20.395-9.568-31.61 45.692-57.901 40.89-89.345 11.937-136.575-57.392-93.622 22.565-125.319 22.455-187.758-.073-41.005-41.264-49.577-24.442-71.699 4.208-5.534 16.876-14.627 35.359 1.969 69.336 62.256 10.012 134.209-.504 156.844-11.108 23.909-8.889 43.083-8.89 44.871ZM463.992 344.986c5.231 50.808 61.671 74.893 33.865 156.094-7.743 22.612-38.143 75.581-60.212 67.864-16.179-5.657-18.418-20.395-9.568-31.61 45.692-57.901 40.89-89.345 11.937-136.575-57.392-93.622 22.565-125.319 22.455-187.758-.073-41.005-41.264-49.577-24.442-71.699 4.208-5.534 16.876-14.627 35.359 1.969 69.336 62.256 10.012 134.209-.504 156.844-11.108 23.909-8.889 43.083-8.89 44.871Z" clip-rule="evenodd"></path></svg>
static/logo-dropanchor.png

This is a binary file and will not be displayed.

+93 -21
templates/en-us/create_event.alpine.html
··· 335 335 </button> 336 336 337 337 <template x-if="showLocationSuggestions && locationSuggestions.length > 0"> 338 - <div class="box mt-2" style="background-color: #f9f9f9; padding: 0.75rem;"> 339 - <p class="is-size-7 has-text-grey mb-2">Your saved locations:</p> 340 - <template x-for="(suggestion, index) in locationSuggestions" :key="index"> 341 - <button 342 - type="button" 343 - @click="applyLocationSuggestion(suggestion)" 344 - class="button is-small is-fullwidth is-justify-content-flex-start mb-2" 345 - style="white-space: normal; height: auto; text-align: left;"> 346 - <span class="icon is-small"> 347 - <i class="fas fa-map-marker-alt"></i> 338 + <div class="mt-3"> 339 + <!-- Search Input --> 340 + <div class="field"> 341 + <div class="control has-icons-left"> 342 + <input 343 + type="text" 344 + class="input is-small" 345 + placeholder="Filter locations..." 346 + x-model="locationFilter" 347 + @keydown.escape="locationFilter = ''"> 348 + <span class="icon is-small is-left"> 349 + <i class="fas fa-search"></i> 348 350 </span> 349 - <span> 350 - <strong x-text="suggestion.name || 'Unknown location'"></strong> 351 - <span class="is-size-7 has-text-grey" x-text="[suggestion.locality, suggestion.region].filter(Boolean).join(', ') ? ', ' + [suggestion.locality, suggestion.region].filter(Boolean).join(', ') : ''"></span> 352 - </span> 351 + </div> 352 + </div> 353 + 354 + <!-- Results Table --> 355 + <div class="table-container" style="max-height: 250px; overflow-y: auto;"> 356 + <table class="table is-fullwidth is-hoverable is-narrow is-striped"> 357 + <thead> 358 + <tr> 359 + <th style="width: 24px;"></th> 360 + <th>Name</th> 361 + <th>Location</th> 362 + </tr> 363 + </thead> 364 + <tbody> 365 + <template x-for="(suggestion, index) in filteredLocationSuggestions" :key="index"> 366 + <tr 367 + @click="applyLocationSuggestion(suggestion)" 368 + style="cursor: pointer;"> 369 + <td style="padding: 0.25em;"> 370 + <img x-show="suggestion.source && suggestion.source.includes('beaconbits')" 371 + src="/static/logo-beaconbits.svg" 372 + alt="Beaconbits" 373 + style="width: 18px; height: 18px; vertical-align: middle;"> 374 + <img x-show="suggestion.source && suggestion.source.includes('dropanchor')" 375 + src="/static/logo-dropanchor.png" 376 + alt="Drop Anchor" 377 + style="width: 18px; height: 18px; vertical-align: middle;"> 378 + <img x-show="suggestion.source && suggestion.source.includes('calendar')" 379 + src="/static/logo-160x160.png" 380 + alt="Smokesignal" 381 + style="width: 18px; height: 18px; vertical-align: middle;"> 382 + </td> 383 + <td> 384 + <span x-text="suggestion.name || '—'"></span> 385 + </td> 386 + <td> 387 + <span class="is-size-7" x-text="[suggestion.street, suggestion.locality, suggestion.region, suggestion.postal_code, suggestion.country].filter(Boolean).join(', ') || (suggestion.latitude && suggestion.longitude ? suggestion.latitude + ', ' + suggestion.longitude : '—')"></span> 388 + </td> 389 + </tr> 390 + </template> 391 + </tbody> 392 + </table> 393 + </div> 394 + 395 + <!-- No Results --> 396 + <p x-show="filteredLocationSuggestions.length === 0 && locationFilter.trim()" 397 + class="is-size-7 has-text-grey mt-2"> 398 + No locations match "<span x-text="locationFilter"></span>" 399 + </p> 400 + 401 + <!-- Actions --> 402 + <div class="mt-2"> 403 + <button type="button" @click="showLocationSuggestions = false; locationFilter = ''" 404 + class="button is-small is-ghost"> 405 + Hide 353 406 </button> 354 - </template> 355 - <button 356 - type="button" 357 - @click="showLocationSuggestions = false" 358 - class="button is-small is-ghost"> 359 - Hide 360 - </button> 407 + </div> 361 408 </div> 362 409 </template> 363 410 ··· 717 764 loadingSuggestions: false, 718 765 showLocationSuggestions: false, 719 766 locationFeedback: null, 767 + locationFilter: '', 720 768 721 769 init() { 722 770 // Load existing location data from server if present ··· 804 852 return this.formData.thumbnail_cid ? `/content/${this.formData.thumbnail_cid}.png` : null; 805 853 }, 806 854 855 + get filteredLocationSuggestions() { 856 + if (!this.locationFilter.trim()) { 857 + return this.locationSuggestions; 858 + } 859 + const query = this.locationFilter.toLowerCase().trim(); 860 + return this.locationSuggestions.filter(suggestion => { 861 + const searchFields = [ 862 + suggestion.name, 863 + suggestion.street, 864 + suggestion.locality, 865 + suggestion.region, 866 + suggestion.postal_code, 867 + suggestion.country, 868 + ].filter(Boolean).map(f => f.toLowerCase()); 869 + 870 + // Fuzzy match: check if all query terms appear in any field 871 + const queryTerms = query.split(/\s+/); 872 + return queryTerms.every(term => 873 + searchFields.some(field => field.includes(term)) 874 + ); 875 + }); 876 + }, 877 + 807 878 addLocation() { 808 879 this.formData.locations.push({ 809 880 country: '', ··· 892 963 } 893 964 894 965 this.showLocationSuggestions = false; 966 + this.locationFilter = ''; 895 967 }, 896 968 897 969 addLink() {