This repository has no description
0

Configure Feed

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

add canceler

+2213
+9
src/App.jsx
··· 22 22 import Login from './components/Login/Login'; 23 23 import LoginCallback from './components/Login/LoginCallback'; 24 24 import Verifier from './components/Verifier/Verifier'; 25 + import Canceler from './components/Canceler/Canceler'; 25 26 import { AuthProvider } from './contexts/AuthContext'; 26 27 import ProtectedRoute from './components/ProtectedRoute'; 27 28 import "./App.css"; ··· 57 58 element={ 58 59 <ProtectedRoute> 59 60 <Verifier /> 61 + </ProtectedRoute> 62 + } 63 + /> 64 + <Route 65 + path="/canceler" 66 + element={ 67 + <ProtectedRoute> 68 + <Canceler /> 60 69 </ProtectedRoute> 61 70 } 62 71 />
+572
src/components/Canceler/Canceler.css
··· 1 + /* frontend-cred-blue/src/components/Verifier/Verifier.css */ 2 + 3 + /* General container */ 4 + .canceler-container { 5 + font-family: "articulat-cf", sans-serif; 6 + max-width: 450px; /* Adjust as needed */ 7 + margin: 20px auto; 8 + padding: 20px; 9 + color: var(--text); 10 + } 11 + 12 + .canceler-container h1, 13 + .canceler-container h2 { 14 + color: var(--button-bg); /* Match heading color */ 15 + text-align: left; 16 + margin-bottom: 15px; 17 + } 18 + 19 + .canceler-container h1 { 20 + font-size: 2em; /* Adjust */ 21 + } 22 + .canceler-intro-container { 23 + margin-bottom: 20px; 24 + } 25 + 26 + /* Apply consistent styles ONLY to h2 elements within canceler sections */ 27 + .canceler-section h2 { 28 + margin-top: 0; 29 + } 30 + 31 + .canceler-page-header { 32 + display: flex; 33 + justify-content: space-between; 34 + align-items: center; 35 + margin-bottom: 15px; 36 + flex-wrap: wrap; /* Allow wrapping on small screens */ 37 + gap: 10px; 38 + } 39 + 40 + .canceler-list { 41 + margin-top: 10px; 42 + } 43 + 44 + .canceler-user-info { 45 + font-size: 0.9em; 46 + color: var(--text-muted, var(--text)); 47 + margin: 0; /* Remove default paragraph margin */ 48 + } 49 + 50 + /* Buttons */ 51 + .canceler-sign-out-button, 52 + .canceler-submit-button, 53 + .canceler-action-button, 54 + .canceler-revoke-button { 55 + background: var(--button-bg); 56 + color: var(--button-text); 57 + border: none; 58 + border-radius: 6px; 59 + padding: 8px 15px; /* Slightly smaller padding */ 60 + font-weight: 700; 61 + font-size: 0.9em; 62 + margin: 0px; 63 + cursor: pointer; 64 + transition: background-color 0.3s ease; 65 + } 66 + 67 + .canceler-sign-out-button:hover, 68 + .canceler-submit-button:hover, 69 + .canceler-action-button:hover, 70 + .canceler-revoke-button:hover { 71 + background: var(--button-hover-bg, #0056b3); /* Use main hover color */ 72 + } 73 + 74 + .canceler-sign-out-button:disabled, 75 + .canceler-submit-button:disabled, 76 + .canceler-action-button:disabled, 77 + .canceler-revoke-button:disabled { 78 + background-color: var(--button-disabled-bg, #cccccc); /* Add disabled style */ 79 + cursor: not-allowed; 80 + opacity: 0.7; 81 + } 82 + 83 + /* Form Styles */ 84 + .canceler-section { 85 + background: var(--navbar-bg); 86 + border: 5px solid var(--card-border); /* Match alt-card border */ 87 + border-radius: 12px; /* Match alt-card radius */ 88 + box-shadow: none; /* Match alt-card shadow */ 89 + padding: 40px 30px; /* Keep increased padding or adjust if alt-card is different */ 90 + margin: 30px auto; /* Match alt-card margin */ 91 + max-width: 95%; /* Match alt-card max-width */ 92 + /* margin-bottom: 20px; */ /* Remove specific margin-bottom */ 93 + } 94 + 95 + .canceler-input-container { 96 + position: relative; /* Needed for autocomplete positioning */ 97 + max-width: 400px; 98 + border: none; /* Remove any potential border */ 99 + outline: none; /* Remove outline on focus */ 100 + padding: 0; /* Remove padding if any was added */ 101 + margin: 0; /* Remove margin if any was added */ 102 + } 103 + 104 + .canceler-form-container { 105 + display: flex; 106 + gap: 10px; 107 + flex-wrap: wrap; /* Allow wrapping */ 108 + padding: 0px; 109 + border: 0px; 110 + /* position: relative; */ /* Removed - no longer needed */ 111 + } 112 + 113 + .canceler-input-field { 114 + flex-grow: 1; /* Take available space */ 115 + border: 2px solid var(--card-border); 116 + border-radius: 6px; 117 + padding: 9px; 118 + font-size: 1em; 119 + background-color: var(--navbar-bg); 120 + color: var(--text); 121 + transition: all 0.3s ease; 122 + font-family: inherit; /* Use main font */ 123 + min-width: 200px; /* Ensure minimum width */ 124 + margin: 0px; 125 + text-align: left; 126 + } 127 + 128 + .canceler-input-field:hover, 129 + .canceler-input-field:focus { 130 + border-color: var(--button-bg); 131 + background-color: var(--background); /* Match main app focus */ 132 + outline: none; 133 + } 134 + 135 + /* Status Box */ 136 + .canceler-status-box { 137 + padding: 15px; 138 + border-radius: 6px; 139 + margin-top: 15px; 140 + margin-bottom: 15px; 141 + text-align: center; 142 + border: 1px solid transparent; /* Base border */ 143 + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; 144 + } 145 + .canceler-status-box-success { 146 + background-color: var(--success-bg, #d4edda); 147 + color: var(--success-text, #155724); 148 + border-color: var(--success-border, #c3e6cb); 149 + } 150 + .canceler-status-box-error { 151 + background-color: var(--error-bg, #f8d7da); 152 + color: var(--error-text, #721c24); 153 + border-color: var(--error-border, #f5c6cb); 154 + } 155 + .canceler-status-box .canceler-intent-link { /* Renamed class */ 156 + color: var(--success-text, #155724); 157 + font-weight: bold; 158 + text-decoration: underline; 159 + } 160 + 161 + .canceler-status-box p { 162 + margin: 163 + 0px; 164 + } 165 + 166 + /* Dark Mode Status Box Styles */ 167 + .dark-mode .canceler-status-box-success { 168 + background-color: var(--success-bg-dark, #1a3a24); /* Example dark variable */ 169 + color: var(--success-text-dark, #a3e9a4); 170 + border-color: var(--success-border-dark, #2a5a34); 171 + } 172 + .dark-mode .canceler-status-box-error { 173 + background-color: var(--error-bg-dark, #4d1f24); /* Example dark variable */ 174 + color: var(--error-text-dark, #f5c6cb); 175 + border-color: var(--error-border-dark, #721c24); 176 + } 177 + .dark-mode .canceler-status-box .canceler-intent-link { 178 + color: var(--success-text-dark, #a3e9a4); 179 + } 180 + 181 + .canceler-list-header h2 { 182 + margin: 0; 183 + padding: 0; 184 + border-bottom: none; /* Explicitly remove border here */ 185 + } 186 + 187 + .canceler-list { 188 + list-style: none; 189 + padding: 0; 190 + margin: 0; /* Reset default ul margins */ 191 + margin-top: 15px; 192 + width: 100%; /* Added */ 193 + box-sizing: border-box; /* Added */ 194 + } 195 + 196 + .canceler-canceler-list { 197 + margin: 0; 198 + padding-left: 15px; 199 + padding-top: 10px; 200 + } 201 + 202 + .canceler-list-item { 203 + display: flex; 204 + align-items: center; 205 + background-color: var(--navbar-bg); /* Match form background */ 206 + padding: 15px; 207 + border: 1px solid var(--card-border); 208 + border-radius: 8px; 209 + margin-bottom: 10px; 210 + flex-wrap: wrap; /* Allow actions to wrap */ 211 + gap: 10px; 212 + width: 100%; /* Added */ 213 + box-sizing: border-box; /* Added */ 214 + } 215 + .canceler-list-item-content { 216 + flex-grow: 1; 217 + } 218 + .canceler-list-item-handle { 219 + font-size: 0.9em; 220 + color: var(--text-muted, var(--text)); 221 + margin: 2px 0; 222 + } 223 + .canceler-list-item-date { 224 + font-size: 0.8em; 225 + color: var(--text-muted, var(--text)); 226 + margin-top: 5px; 227 + } 228 + 229 + .canceler-list-item-actions { 230 + flex-shrink: 0; /* Prevent button shrinking */ 231 + margin-left: auto; /* Added to push button right */ 232 + } 233 + 234 + .canceler-list-item-invalid { 235 + border-left: 5px solid var(--warning-border, orange); /* Highlight invalid items */ 236 + } 237 + 238 + /* New styles for verification list profile links */ 239 + .canceler-profile-link { 240 + display: flex; 241 + flex-direction: column; 242 + text-decoration: none; 243 + color: var(--text); 244 + margin-bottom: 5px; 245 + } 246 + 247 + .canceler-profile-link:hover { 248 + text-decoration: underline; 249 + } 250 + 251 + .canceler-display-name { 252 + font-weight: bold; 253 + font-size: 1.05em; 254 + margin-right: 5px; 255 + } 256 + 257 + /* Add dark mode color for display name */ 258 + .dark-mode .canceler-display-name { 259 + color: #3b9af8; /* Light blue for dark mode */ 260 + } 261 + 262 + .canceler-network-results p { 263 + margin: 0px; 264 + } 265 + 266 + /* Validity status indicators */ 267 + .canceler-validity-status { 268 + display: inline-block; 269 + font-size: 0.9em; 270 + padding: 3px 6px; 271 + border-radius: 4px; 272 + margin-top: 5px; 273 + } 274 + 275 + .canceler-list { 276 + margin-top: 15px; 277 + } 278 + 279 + .canceler-validity-status.valid { 280 + background-color: var(--success-bg, rgba(0, 128, 0, 0.1)); 281 + color: var(--success-text, green); 282 + } 283 + 284 + .canceler-validity-status.invalid { 285 + background-color: var(--error-bg, rgba(255, 0, 0, 0.1)); 286 + color: var(--error-text, red); 287 + } 288 + 289 + .canceler-validity-status.checking { 290 + background-color: var(--warning-bg, rgba(255, 165, 0, 0.1)); 291 + color: var(--warning-text, orange); 292 + } 293 + 294 + /* Dark mode compatibility */ 295 + .dark-mode .canceler-validity-status.valid { 296 + background-color: var(--success-bg-dark, rgba(0, 128, 0, 0.3)); 297 + color: var(--success-text-dark, #a3e9a4); 298 + } 299 + 300 + .dark-mode .canceler-validity-status.invalid { 301 + background-color: var(--error-bg-dark, rgba(255, 0, 0, 0.2)); 302 + color: var(--error-text-dark, #f5c6cb); 303 + } 304 + 305 + .dark-mode .canceler-validity-status.checking { 306 + background-color: var(--warning-bg-dark, rgba(255, 165, 0, 0.2)); 307 + color: var(--warning-text-dark, #ffe4b5); 308 + } 309 + 310 + /* Remove the now-unused validity warning box styles */ 311 + .canceler-validity-warning { 312 + display: none; 313 + } 314 + 315 + /* Media query for mobile optimization */ 316 + @media (max-width: 480px) { 317 + .canceler-list-item { 318 + flex-direction: column; 319 + } 320 + 321 + .canceler-list-item-content { 322 + width: 100%; 323 + margin-bottom: 10px; 324 + } 325 + 326 + .canceler-list-item-actions { 327 + align-self: flex-end; 328 + } 329 + } 330 + 331 + /* Network Verifications */ 332 + .canceler-check-network-button { 333 + font-size: .9em; 334 + margin: 0px; 335 + margin-top: 16.4px; 336 + } 337 + 338 + .canceler-action-button.canceler-refresh-button { 339 + margin: 0px; 340 + } 341 + 342 + .canceler-network-status { 343 + font-style: italic; 344 + color: var(--text-muted, var(--text)); 345 + margin: 10px 0; 346 + } 347 + .canceler-network-results { 348 + margin-top: 0px; 349 + } 350 + 351 + .canceler-additional-context p { 352 + margin-top: 0px; 353 + } 354 + 355 + .canceler-additional-context { 356 + font-size: 0.9em; 357 + color: var(--text-muted, var(--text)); 358 + margin-top: 15px; 359 + border-top: 1px dashed var(--card-border); 360 + padding-top: 17px; 361 + } 362 + .canceler-share-stats-link { 363 + display: inline-block; 364 + margin-top: 15px; 365 + font-size: 0.9em; 366 + font-weight: bold; 367 + } 368 + 369 + .canceler-official-canceler-note { 370 + font-size: 0.9em; 371 + margin: 5px 0; 372 + padding-left: 5px; /* Indent slightly */ 373 + } 374 + 375 + .canceler-network-results { 376 + margin-top: 20px; 377 + } 378 + 379 + .canceler-verified-status { color: var(--success-text, green); } 380 + .canceler-not-verified-status { color: var(--text-muted, grey); } 381 + .canceler-error-status { color: var(--error-text, red); } 382 + .canceler-checking-status, .canceler-idle-status { color: var(--text-muted, grey); } 383 + 384 + /* Styles for Typeahead/Autocomplete Suggestions */ 385 + .canceler-input-wrapper { 386 + position: relative; /* Required for absolute positioning of suggestions */ 387 + } 388 + 389 + .canceler-suggestions-list { 390 + list-style: none; 391 + padding: 0; 392 + margin: 10px 0px 0px 0px; 393 + position: absolute; 394 + top: 100%; /* Position below the input */ 395 + left: 0; 396 + right: 0; 397 + background-color: var(--navbar-bg); /* Match nearby elements */ 398 + border: 2px solid var(--card-border); 399 + border-radius: 6px; /* Round bottom corners */ 400 + max-height: 275px; /* Limit height and allow scroll */ 401 + overflow: clip; 402 + z-index: 1000; /* Ensure it appears above other content */ 403 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 404 + } 405 + 406 + .canceler-suggestion-item { 407 + display: flex; 408 + align-items: center; 409 + padding: 10px 15px; 410 + cursor: pointer; 411 + transition: background-color 0.2s ease; 412 + } 413 + 414 + .canceler-suggestion-item.loading, 415 + .canceler-suggestion-item.none { 416 + font-style: italic; 417 + color: var(--text-muted); 418 + cursor: default; 419 + } 420 + 421 + .canceler-suggestion-item:hover.loading, 422 + .canceler-suggestion-item:hover.none { 423 + background-color: transparent; /* Don't highlight loading/none items */ 424 + color: var(--text-muted); 425 + } 426 + 427 + .canceler-suggestion-avatar { 428 + width: 30px; 429 + height: 30px; 430 + border-radius: 50%; 431 + margin-right: 10px; 432 + object-fit: cover; 433 + flex-shrink: 0; 434 + } 435 + 436 + .canceler-suggestion-text { 437 + display: flex; 438 + flex-direction: column; 439 + overflow: hidden; /* Prevent long text overflow */ 440 + white-space: nowrap; 441 + } 442 + 443 + .canceler-suggestion-name { 444 + font-weight: bold; 445 + text-overflow: ellipsis; 446 + overflow: hidden; 447 + } 448 + 449 + .canceler-suggestion-handle { 450 + font-size: 0.9em; 451 + color: var(--text-muted); 452 + text-overflow: ellipsis; 453 + overflow: hidden; 454 + } 455 + 456 + /* Styles for List Verification */ 457 + .canceler-mode-toggle { 458 + display: flex; 459 + gap: 20px; /* Space between radio buttons */ 460 + margin-bottom: 15px; /* Space below the toggle */ 461 + margin-top: 15px; 462 + padding-bottom: 15px; /* More space */ 463 + border-bottom: 1px solid var(--card-border); /* Separator line */ 464 + } 465 + 466 + .canceler-mode-toggle label { 467 + cursor: pointer; 468 + display: flex; 469 + align-items: center; 470 + gap: 5px; /* Space between radio and text */ 471 + color: var(--text); 472 + } 473 + 474 + .canceler-mode-toggle input[type="radio"] { 475 + cursor: pointer; 476 + /* Optional: style the radio button itself */ 477 + } 478 + 479 + .canceler-list-select { 480 + flex-grow: 1; /* Take available space */ 481 + border: 2px solid var(--card-border); 482 + border-radius: 6px; 483 + padding: 9px; 484 + font-size: 1em; 485 + background-color: var(--navbar-bg); 486 + color: var(--text); 487 + transition: all 0.3s ease; 488 + font-family: inherit; /* Use main font */ 489 + min-width: 200px; /* Ensure minimum width */ 490 + margin: 0px; 491 + max-width: 100%; 492 + } 493 + 494 + .canceler-list-select:hover, 495 + .canceler-list-select:focus { 496 + border-color: var(--button-bg); 497 + background-color: var(--background); /* Match main app focus */ 498 + outline: none; 499 + } 500 + 501 + .canceler-list-select:disabled { 502 + background-color: var(--button-disabled-bg, #cccccc); 503 + cursor: not-allowed; 504 + opacity: 0.7; 505 + } 506 + 507 + /* Progress Indicator */ 508 + .canceler-status-box-progress p { 509 + margin: 0; /* Reset margin for progress text */ 510 + font-style: italic; 511 + } 512 + 513 + .canceler-bulk-progress { 514 + font-style: italic; 515 + color: var(--text-muted, var(--text)); 516 + margin-top: 8px; 517 + font-size: 0.9em; 518 + } 519 + 520 + /* Verification Options */ 521 + .canceler-options { 522 + margin-bottom: 15px; 523 + margin-top: 10px; /* Added margin top */ 524 + } 525 + 526 + .canceler-options label { 527 + cursor: pointer; 528 + display: flex; 529 + align-items: center; 530 + gap: 5px; 531 + color: var(--text); 532 + font-size: 0.9em; 533 + } 534 + 535 + .canceler-options input[type="checkbox"] { 536 + cursor: pointer; 537 + margin-right: 5px; /* Space between checkbox and label text */ 538 + } 539 + 540 + 541 + /* Time-based Revocation Styles */ 542 + .canceler-time-revoke-wrapper { 543 + margin-top: 15px; 544 + } 545 + 546 + .canceler-time-revoke-wrapper p { 547 + margin-bottom: 10px; 548 + color: var(--text); 549 + } 550 + 551 + .canceler-time-range-selector { 552 + display: flex; 553 + gap: 15px; 554 + margin-bottom: 15px; 555 + flex-wrap: wrap; /* Allow wrapping */ 556 + } 557 + 558 + .canceler-time-range-selector label { 559 + cursor: pointer; 560 + display: flex; 561 + align-items: center; 562 + gap: 5px; 563 + color: var(--text); 564 + } 565 + 566 + .canceler-time-range-selector input[type="radio"] { 567 + cursor: pointer; 568 + } 569 + 570 + .canceler-time-revoke-wrapper .canceler-revoke-button { 571 + /* Optional: Specific styling if needed, otherwise inherits from .canceler-revoke-button */ 572 + }
+1632
src/components/Canceler/Canceler.js
··· 1 + import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; 2 + import { useAuth } from '../../contexts/AuthContext'; 3 + import { Agent } from '@atproto/api'; 4 + import './Canceler.css'; 5 + 6 + // Define trusted verifiers (updated list) 7 + const TRUSTED_VERIFIERS = [ 8 + 'bsky.app', 9 + 'nytimes.com', 10 + 'wired.com', 11 + 'theathletic.bsky.social' 12 + ]; 13 + 14 + // Helper function modified to handle direct fetch or agent calls 15 + // Now accepts an optional 'useDirectFetch' flag and the direct URL if needed 16 + // And accepts apiContext and methodName for correct 'this' binding 17 + async function fetchAllPaginated(apiContext, methodName, initialParams, useDirectFetch = false, directUrl = null) { 18 + let results = []; 19 + let cursor = initialParams.cursor; 20 + const params = { ...initialParams }; // Copy initial params 21 + // Determine operation name 22 + const operationName = methodName || (directUrl || 'directFetch'); 23 + console.log(`fetchAllPaginated: Starting ${operationName} with initialParams:`, initialParams); 24 + 25 + let currentUrl = directUrl; // Use direct URL if provided 26 + 27 + do { 28 + try { 29 + let responseData; 30 + if (useDirectFetch && currentUrl) { 31 + // Handle pagination for direct fetch 32 + const url = new URL(currentUrl); 33 + if (cursor) { 34 + url.searchParams.set('cursor', cursor); 35 + } 36 + // Add other params like limit (ensure initialParams doesn't duplicate) 37 + Object.entries(params).forEach(([key, value]) => { 38 + if (key !== 'cursor' && !url.searchParams.has(key)) { 39 + url.searchParams.set(key, value); 40 + } 41 + }); 42 + // console.log(`fetchAllPaginated: Direct fetch URL: ${url.toString()}`); 43 + const response = await fetch(url.toString()); 44 + if (!response.ok) throw new Error(`HTTP error ${response.status}`); 45 + responseData = await response.json(); 46 + } else if (apiContext && methodName) { 47 + // Use agent method with correct context 48 + if (cursor) { 49 + params.cursor = cursor; 50 + } 51 + // Call the method using the provided context 52 + const response = await apiContext[methodName](params); 53 + if (!response || !response.data) { 54 + console.warn(`fetchAllPaginated: Invalid agent response for ${operationName}`, response); 55 + break; 56 + } 57 + responseData = response.data; 58 + } else { 59 + console.error("fetchAllPaginated: Called without apiContext/methodName or direct URL"); 60 + break; 61 + } 62 + 63 + // Find results array 64 + const listKey = Object.keys(responseData).find(key => Array.isArray(responseData[key])); 65 + if (listKey && responseData[listKey]) { 66 + results = results.concat(responseData[listKey]); 67 + } 68 + cursor = responseData.cursor; 69 + 70 + } catch (error) { 71 + console.error(`Error during paginated fetch for ${operationName}:`, error); 72 + cursor = undefined; 73 + } 74 + } while (cursor); 75 + 76 + console.log(`fetchAllPaginated: Finished ${operationName}, total items: ${results.length}`); 77 + return results; 78 + } 79 + 80 + // Updated function to get PDS endpoint from PLC directory OR well-known URI for did:web 81 + async function getPdsEndpoint(did) { 82 + let didDocUrl; 83 + if (did.startsWith('did:plc:')) { 84 + didDocUrl = `https://plc.directory/${did}`; 85 + } else if (did.startsWith('did:web:')) { 86 + const domain = did.substring(8); // Extract domain after 'did:web:' 87 + const decodedDomain = decodeURIComponent(domain); 88 + didDocUrl = `https://${decodedDomain}/.well-known/did.json`; 89 + } else { 90 + console.warn(`Unsupported DID method for PDS lookup: ${did}`); 91 + return null; 92 + } 93 + 94 + try { 95 + const response = await fetch(didDocUrl); 96 + if (!response.ok) { 97 + console.warn(`Could not resolve DID document for ${did} at ${didDocUrl}: ${response.status}`); 98 + return null; 99 + } 100 + const didDoc = await response.json(); 101 + const service = didDoc.service?.find(s => s.type === 'AtprotoPersonalDataServer'); 102 + const endpoint = service?.serviceEndpoint || null; 103 + if (!endpoint) { 104 + console.warn(`No AtprotoPersonalDataServer service endpoint found in DID document for ${did}`); 105 + } 106 + return endpoint; 107 + } catch (error) { 108 + console.error(`Error fetching or parsing DID document for ${did} from ${didDocUrl}:`, error); 109 + return null; 110 + } 111 + } 112 + 113 + // Renamed component to Canceler 114 + function Canceler() { 115 + // Use the main app's AuthContext 116 + const { session, loading: isAuthLoading, error: authError, logout: signOut, isAuthenticated } = useAuth(); 117 + const [targetHandle, setTargetHandle] = useState(''); 118 + const [statusMessage, setStatusMessage] = useState(''); 119 + const [revokeStatusMessage, setRevokeStatusMessage] = useState(''); 120 + const [isVerifying, setIsVerifying] = useState(false); 121 + const [isRevoking, setIsRevoking] = useState(false); 122 + const [agent, setAgent] = useState(null); 123 + const [userInfo, setUserInfo] = useState(null); 124 + const [verifications, setVerifications] = useState([]); 125 + const [isLoadingVerifications, setIsLoadingVerifications] = useState(false); 126 + const [networkVerifications, setNetworkVerifications] = useState({ 127 + mutualsVerifiedMe: [], 128 + followsVerifiedMe: [], 129 + mutualsVerifiedAnyone: 0, 130 + followsVerifiedAnyone: 0, 131 + fetchedMutualsCount: 0, 132 + fetchedFollowsCount: 0, 133 + }); 134 + const [isLoadingNetwork, setIsLoadingNetwork] = useState(false); 135 + const [networkChecked, setNetworkChecked] = useState(false); 136 + const [isCheckingValidity, setIsCheckingValidity] = useState(false); 137 + const [networkStatusMessage, setNetworkStatusMessage] = useState(''); 138 + const [officialVerifiersStatus, setOfficialVerifiersStatus] = useState({}); 139 + const [suggestions, setSuggestions] = useState([]); // State for typeahead suggestions 140 + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); // State for suggestion loading indicator 141 + const [showSuggestions, setShowSuggestions] = useState(false); // Control suggestion list visibility 142 + const debounceTimeoutRef = useRef(null); // Ref for debounce timer 143 + const suggestionListRef = useRef(null); // Ref for suggestion list to handle clicks outside 144 + 145 + // State for list verification 146 + const [verifyMode, setVerifyMode] = useState('single'); // 'single' or 'list' 147 + const [userLists, setUserLists] = useState([]); 148 + const [selectedListUri, setSelectedListUri] = useState(''); 149 + const [isFetchingLists, setIsFetchingLists] = useState(false); 150 + const [bulkVerifyStatus, setBulkVerifyStatus] = useState(''); // Status message for bulk operations 151 + const [bulkVerifyProgress, setBulkVerifyProgress] = useState(''); // Progress indicator (e.g., "10/50") 152 + 153 + // State for list revocation 154 + const [revokeMode, setRevokeMode] = useState('single'); // 'single' or 'list' or 'time' 155 + const [selectedListUriForRevoke, setSelectedListUriForRevoke] = useState(''); 156 + const [bulkRevokeStatus, setBulkRevokeStatus] = useState(''); // Status message for bulk revoke 157 + const [bulkRevokeProgress, setBulkRevokeProgress] = useState(''); // Progress for bulk revoke 158 + 159 + // State for filtering verified accounts 160 + const [verificationSearchTerm, setVerificationSearchTerm] = useState(''); 161 + 162 + // State for time-based revocation 163 + const [revokeTimeRange, setRevokeTimeRange] = useState('30m'); // Default: 30 minutes 164 + 165 + // State for verification list pagination 166 + const [verificationsCursor, setVerificationsCursor] = useState(null); 167 + const [isLoadingMoreVerifications, setIsLoadingMoreVerifications] = useState(false); 168 + 169 + // Verification options 170 + const [skipDuplicates, setSkipDuplicates] = useState(true); 171 + 172 + const followsListUri = 'special:follows'; // Constant for the special URI 173 + 174 + useEffect(() => { 175 + if (session) { 176 + const agentInstance = new Agent(session); 177 + setAgent(agentInstance); 178 + 179 + agentInstance.api.app.bsky.actor.getProfile({ actor: session.did }) 180 + .then(res => { 181 + console.log('Logged-in user profile fetched successfully:', res.data); 182 + setUserInfo(res.data); 183 + }) 184 + .catch(err => { 185 + console.error("Failed to fetch user profile:", err); 186 + setUserInfo({ handle: session.handle, displayName: session.displayName || session.handle, did: session.did }); 187 + }); 188 + } else { 189 + setAgent(null); 190 + setUserInfo(null); 191 + } 192 + }, [session]); 193 + 194 + // Define checkVerificationsValidity *before* fetchVerifications because fetchVerifications depends on it 195 + const checkVerificationsValidity = useCallback(async (verificationsList) => { 196 + if (!verificationsList || verificationsList.length === 0) { 197 + console.log("checkVerificationsValidity called with empty or null list."); 198 + return; // Exit early if list is empty 199 + } 200 + 201 + setIsCheckingValidity(true); 202 + // Create a mutable copy to update status 203 + const updatedVerifications = verificationsList.map(v => ({ ...v })); 204 + try { 205 + const batchSize = 5; 206 + for (let i = 0; i < updatedVerifications.length; i += batchSize) { 207 + const batch = updatedVerifications.slice(i, i + batchSize); 208 + await Promise.all(batch.map(async (verification, index) => { 209 + const batchIndex = i + index; 210 + try { 211 + // *** Get the specific PDS for the verified user *** 212 + const targetDid = verification.subject; 213 + /* // Remove PDS lookup - use public API instead 214 + const pdsEndpoint = await getPdsEndpoint(targetDid); 215 + 216 + if (!pdsEndpoint) { 217 + throw new Error(`Could not find PDS for ${verification.handle || targetDid}`); 218 + } 219 + */ 220 + 221 + // *** Use direct fetch from the public AppView to get the profile *** 222 + const publicApiBase = 'https://public.api.bsky.app'; 223 + const profileUrl = `${publicApiBase}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(targetDid)}`; 224 + const profileResponse = await fetch(profileUrl); 225 + 226 + if (!profileResponse.ok) { 227 + // If profile fetch fails (e.g., 404), mark validity check failed 228 + throw new Error(`Failed to fetch profile from public API: ${profileResponse.status}`); 229 + } 230 + const profileData = await profileResponse.json(); 231 + 232 + // Check if handle and displayName still match 233 + const currentHandle = profileData.handle; 234 + const currentDisplayName = profileData.displayName || profileData.handle; 235 + 236 + updatedVerifications[batchIndex].validityChecked = true; 237 + updatedVerifications[batchIndex].isValid = 238 + currentHandle === verification.handle && 239 + currentDisplayName === verification.displayName; 240 + 241 + if (!updatedVerifications[batchIndex].isValid) { 242 + updatedVerifications[batchIndex].currentHandle = currentHandle; 243 + updatedVerifications[batchIndex].currentDisplayName = currentDisplayName; 244 + } 245 + } catch (err) { 246 + console.error(`Failed to check validity for ${verification.handle || verification.subject}:`, err); 247 + updatedVerifications[batchIndex].validityChecked = true; 248 + updatedVerifications[batchIndex].isValid = false; 249 + updatedVerifications[batchIndex].validityError = true; 250 + } 251 + })); 252 + // Update state after each batch completes to reflect progress 253 + // Use functional update to ensure we're working with the latest state 254 + setVerifications(prev => 255 + prev.map(v => updatedVerifications.find(uv => uv.uri === v.uri) || v) 256 + ); 257 + } 258 + console.log('Verified all records validity (batch processed):', updatedVerifications); 259 + } catch (error) { 260 + console.error('Error during batch processing for validity check:', error); 261 + } finally { 262 + setIsCheckingValidity(false); 263 + } 264 + }, []); // Empty dependency array is likely correct as setters are stable & getPdsEndpoint is global 265 + 266 + const fetchVerifications = useCallback(async (cursor) => { 267 + if (!agent || !session) return; 268 + 269 + // Determine loading state based on whether a cursor is provided 270 + if (cursor) { 271 + setIsLoadingMoreVerifications(true); 272 + } else { 273 + setIsLoadingVerifications(true); 274 + setVerifications([]); // Clear existing on initial fetch 275 + setVerificationsCursor(null); // Reset cursor on initial fetch 276 + } 277 + 278 + try { 279 + const params = { 280 + repo: session.did, 281 + collection: 'app.bsky.graph.cancellation', 282 + limit: 25, // Fetch 25 at a time (changed from 100) 283 + }; 284 + if (cursor) { 285 + params.cursor = cursor; 286 + } 287 + 288 + const response = await agent.api.com.atproto.repo.listRecords(params); 289 + console.log('Fetched verifications page:', response.data); 290 + 291 + if (response.data.records && response.data.records.length > 0) { 292 + const newFormatted = response.data.records.map(record => ({ 293 + uri: record.uri, 294 + cid: record.cid, 295 + handle: record.value.handle, 296 + displayName: record.value.displayName, 297 + subject: record.value.subject, 298 + createdAt: record.value.createdAt, 299 + isValid: true, // Assume valid initially 300 + validityChecked: false 301 + })); 302 + 303 + // Append if loading more, replace if initial fetch 304 + setVerifications(prevVerifications => 305 + cursor ? [...prevVerifications, ...newFormatted] : newFormatted 306 + ); 307 + setVerificationsCursor(response.data.cursor || null); // Store the new cursor 308 + 309 + // Get the updated list *after* state update (or construct it) 310 + const updatedVerifications = cursor ? [...verifications, ...newFormatted] : newFormatted; 311 + 312 + // Check validity for the entire updated list 313 + // Consider optimizing this later if performance is an issue 314 + checkVerificationsValidity(updatedVerifications); 315 + } else { 316 + // If initial fetch resulted in no records, ensure list is empty 317 + if (!cursor) { 318 + setVerifications([]); 319 + setVerificationsCursor(null); 320 + } 321 + // If loading more resulted in no records, just clear the cursor 322 + if (cursor) { 323 + setVerificationsCursor(null); 324 + } 325 + } 326 + } catch (error) { 327 + console.error('Failed to fetch verifications:', error); 328 + // Use appropriate status based on load type 329 + const statusMsg = `Failed to load verifications: ${error.message || 'Unknown error'}`; 330 + if(cursor) setRevokeStatusMessage(statusMsg); // Show error near list 331 + else setStatusMessage(statusMsg); // Show error near top form 332 + } finally { 333 + if (cursor) { 334 + setIsLoadingMoreVerifications(false); 335 + } else { 336 + setIsLoadingVerifications(false); 337 + } 338 + } 339 + // Note: Removing 'verifications' from dependency array to prevent potential infinite loop 340 + // The logic relies on setVerifications using the functional update form or constructing the new list manually. 341 + }, [agent, session, checkVerificationsValidity]); 342 + 343 + const checkNetworkVerifications = useCallback(async () => { 344 + if (!agent || !session || !userInfo) { 345 + console.warn("checkNetworkVerifications: Agent, session, or userInfo not available."); 346 + return; 347 + } 348 + setIsLoadingNetwork(true); 349 + setNetworkChecked(false); 350 + setNetworkVerifications({ mutualsVerifiedMe: [], followsVerifiedMe: [], mutualsVerifiedAnyone: 0, followsVerifiedAnyone: 0, fetchedMutualsCount: 0, fetchedFollowsCount: 0 }); 351 + setNetworkStatusMessage("Fetching network lists (mutuals, follows)..."); 352 + 353 + try { 354 + console.log("checkNetworkVerifications: Fetching follows (public) and attempting direct getKnownFollowers..."); 355 + 356 + // Fetch follows using direct fetch 357 + const followsUrl = `https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows`; 358 + const followsParams = { actor: session.did, limit: 100 }; 359 + const follows = await fetchAllPaginated(null, null, followsParams, true, followsUrl); 360 + 361 + // *** Fetch Known Followers Directly (First Page Only for Test) *** 362 + let mutuals = []; 363 + try { 364 + const knownFollowersResponse = await agent.api.app.bsky.graph.getKnownFollowers({ 365 + actor: session.did, 366 + limit: 100 // Fetch first page 367 + }); 368 + if (knownFollowersResponse?.data?.followers) { 369 + mutuals = knownFollowersResponse.data.followers; 370 + console.log(`Direct getKnownFollowers call successful, got ${mutuals.length} mutuals.`); 371 + } else { 372 + console.warn("Direct getKnownFollowers call returned unexpected structure:", knownFollowersResponse); 373 + } 374 + } catch (knownFollowersError) { 375 + console.error("Direct getKnownFollowers call failed:", knownFollowersError); 376 + // Set status message to indicate failure for this part 377 + setNetworkStatusMessage("Failed to fetch mutuals/known followers."); 378 + // Optionally, proceed without mutuals or stop the check 379 + // For now, let's continue with just follows if mutuals failed 380 + } 381 + 382 + // Now mutuals contains only the first page, or is empty on error. 383 + // The rest of the logic will proceed, but mutuals data might be incomplete or missing. 384 + 385 + console.log(`checkNetworkVerifications: Fetched ${follows.length} follows, ${mutuals.length} known followers (first page).`); 386 + setNetworkStatusMessage(`Processing ${follows.length} follows and ${mutuals.length} known followers...`); 387 + setNetworkVerifications(prev => ({ ...prev, fetchedMutualsCount: mutuals.length, fetchedFollowsCount: follows.length })); 388 + 389 + const followsSet = new Set(follows.map(f => f.did)); 390 + const mutualsSet = new Set(mutuals.map(m => m.did)); 391 + const allProfilesMap = new Map(); 392 + [...follows, ...mutuals].forEach(user => { if (user && user.did && !allProfilesMap.has(user.did)) allProfilesMap.set(user.did, user); }); 393 + const uniqueUserDids = Array.from(allProfilesMap.keys()); 394 + 395 + if (uniqueUserDids.length === 0) { 396 + setNetworkStatusMessage("No mutuals or follows found."); 397 + setIsLoadingNetwork(false); 398 + setNetworkChecked(true); 399 + return; 400 + } 401 + 402 + console.log(`checkNetworkVerifications: Checking ${uniqueUserDids.length} unique users...`); 403 + let results = { mutualsVerifiedMe: [], followsVerifiedMe: [], mutualsVerifiedAnyone: 0, followsVerifiedAnyone: 0 }; 404 + const batchSize = 10; 405 + 406 + for (let i = 0; i < uniqueUserDids.length; i += batchSize) { 407 + const batchDids = uniqueUserDids.slice(i, i + batchSize); 408 + setNetworkStatusMessage(`Checking verification records... (${i + batchDids.length}/${uniqueUserDids.length})`); 409 + 410 + const batchPromises = batchDids.map(async (did) => { 411 + const profile = allProfilesMap.get(did); 412 + if (!profile) return null; 413 + const isMutual = mutualsSet.has(did); 414 + const isFollow = followsSet.has(did); 415 + 416 + const pdsEndpoint = await getPdsEndpoint(did); 417 + if (!pdsEndpoint) { 418 + console.warn(`Skipping verification check for ${profile.handle || did} (no PDS found).`); 419 + return null; 420 + } 421 + 422 + let foundVerificationForMe = null; 423 + let hasVerifiedAnyone = false; 424 + 425 + try { 426 + // *** Use fetchAllPaginated with direct fetch for listRecords *** 427 + const listRecordsUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.listRecords`; 428 + const listRecordsParams = { repo: did, collection: 'app.bsky.graph.cancellation', limit: 100 }; 429 + 430 + const verificationRecords = await fetchAllPaginated( 431 + null, 432 + null, 433 + listRecordsParams, 434 + true, // Use direct fetch 435 + listRecordsUrl 436 + ); 437 + 438 + if (verificationRecords.length > 0) { 439 + hasVerifiedAnyone = true; 440 + // Check if any record verifies the logged-in user 441 + const matchingRecord = verificationRecords.find(record => record.value?.subject === session.did); 442 + if (matchingRecord) { 443 + foundVerificationForMe = matchingRecord; 444 + } 445 + } 446 + } catch (err) { 447 + console.warn(`Error processing records for ${profile?.handle || did} on ${pdsEndpoint}:`, err); 448 + } 449 + 450 + // Return data for aggregation 451 + return { 452 + isMutual, 453 + isFollow, 454 + profile, 455 + hasVerifiedAnyone, 456 + foundVerificationForMe 457 + }; 458 + }); 459 + 460 + // Process results from the batch 461 + const batchResults = await Promise.all(batchPromises); 462 + batchResults.forEach(result => { 463 + if (!result) return; // Skip if PDS lookup failed or other issue 464 + if (result.hasVerifiedAnyone) { 465 + if (result.isMutual) results.mutualsVerifiedAnyone++; 466 + if (result.isFollow) results.followsVerifiedAnyone++; 467 + } 468 + if (result.foundVerificationForMe) { 469 + const accountInfo = { ...result.profile, verification: result.foundVerificationForMe }; 470 + if (result.isMutual) results.mutualsVerifiedMe.push(accountInfo); 471 + if (result.isFollow) results.followsVerifiedMe.push(accountInfo); 472 + } 473 + }); 474 + 475 + // Update state incrementally after each batch 476 + setNetworkVerifications(prev => ({ 477 + ...prev, 478 + mutualsVerifiedMe: [...results.mutualsVerifiedMe], 479 + followsVerifiedMe: [...results.followsVerifiedMe], 480 + mutualsVerifiedAnyone: results.mutualsVerifiedAnyone, 481 + followsVerifiedAnyone: results.followsVerifiedAnyone, 482 + })); 483 + } 484 + 485 + console.log('checkNetworkVerifications: Check complete.', results); 486 + setNetworkStatusMessage("Network verification check complete."); 487 + 488 + } catch (error) { 489 + console.error('Error during network verification check:', error); 490 + setStatusMessage(`Error checking network: ${error.message || 'Unknown error'}`); 491 + setNetworkStatusMessage(""); 492 + } finally { 493 + setIsLoadingNetwork(false); 494 + setNetworkChecked(true); 495 + setNetworkStatusMessage(''); 496 + } 497 + }, [agent, session, userInfo]); 498 + 499 + // Function to fetch user's lists 500 + const fetchUserLists = useCallback(async () => { 501 + if (!agent || !session?.did) { 502 + console.warn("fetchUserLists: Agent or session.did not available."); 503 + return; 504 + } 505 + setIsFetchingLists(true); 506 + setUserLists([]); // Clear previous lists 507 + setStatusMessage(''); // Clear general status 508 + setBulkVerifyStatus('Fetching your lists...'); // Use bulk status for list fetching message 509 + try { 510 + const lists = await fetchAllPaginated( 511 + agent.api.app.bsky.graph, // The context object 512 + 'getLists', // The method name as a string 513 + { actor: session.did, limit: 100 }, // Initial parameters 514 + false // Not using direct fetch here 515 + ); 516 + console.log(`Fetched ${lists.length} lists for user ${session.handle}`); 517 + 518 + // Prepend the special "Follows" list 519 + const followsPseudoList = { 520 + uri: 'special:follows', 521 + name: 'My Follows', 522 + // Use follows count from userInfo if available 523 + listItemCount: userInfo?.followsCount ?? 0 // Default to 0 if not found 524 + }; 525 + 526 + setUserLists([followsPseudoList, ...(lists || [])]); // Add follows list at the beginning 527 + 528 + if (lists.length === 0) { 529 + // Adjust status message if only the pseudo-list exists 530 + setBulkVerifyStatus('You have not created any custom lists yet.'); 531 + } else { 532 + setBulkVerifyStatus(''); // Clear status on success if lists were found 533 + } 534 + } catch (error) { 535 + console.error('Failed to fetch user lists:', error); 536 + setBulkVerifyStatus(`Failed to fetch lists: ${error.message || 'Unknown error'}`); 537 + } finally { 538 + setIsFetchingLists(false); 539 + // Clear status if it was just 'Fetching...' and no error occurred but no lists found 540 + if (!bulkVerifyStatus.includes('Failed') && !bulkVerifyStatus.includes('You have not created')) { 541 + setBulkVerifyStatus(''); 542 + } 543 + } 544 + }, [agent, session, userInfo]); 545 + 546 + useEffect(() => { 547 + if (agent && userInfo) { // Wait for both agent and userInfo 548 + fetchVerifications(); // Initial fetch (no cursor) 549 + fetchUserLists(); // Fetch lists when agent and userInfo are ready 550 + } 551 + // Intentionally not depending on fetchVerifications/fetchUserLists to avoid loops if they change identity 552 + // We only want this effect to run when agent or userInfo changes. 553 + }, [agent, userInfo, fetchVerifications, fetchUserLists]); // Add userInfo dependency 554 + 555 + const checkOfficialVerification = useCallback(async () => { 556 + if (!session?.did) return; 557 + const initialStatuses = {}; 558 + TRUSTED_VERIFIERS.forEach(id => { initialStatuses[id] = 'checking'; }); 559 + setOfficialVerifiersStatus(initialStatuses); 560 + // No need for publicAgent instance here 561 + // const publicAgent = new Agent({ service: 'https://public.api.bsky.app' }); 562 + 563 + await Promise.all(TRUSTED_VERIFIERS.map(async (verifierIdentifier) => { 564 + let verifierDid = null; 565 + let verifierHandle = verifierIdentifier; 566 + let currentStatus = 'checking'; 567 + try { 568 + // Resolve handle using direct fetch if necessary 569 + if (!verifierIdentifier.startsWith('did:')) { 570 + const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(verifierIdentifier)}`; 571 + const resolveResponse = await fetch(resolveUrl); 572 + if (!resolveResponse.ok) throw new Error(`Resolve handle failed: ${resolveResponse.status}`); 573 + const resolveData = await resolveResponse.json(); 574 + verifierDid = resolveData.did; 575 + } else { 576 + verifierDid = verifierIdentifier; 577 + // Optionally fetch profile handle for display using direct fetch 578 + try { 579 + const profileUrl = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(verifierDid)}`; 580 + const profileResponse = await fetch(profileUrl); 581 + if (profileResponse.ok) { 582 + const profileData = await profileResponse.json(); 583 + verifierHandle = profileData.handle; 584 + } 585 + } catch { /* ignore */ } 586 + } 587 + 588 + if (!verifierDid) throw new Error('Could not resolve identifier'); 589 + const pdsEndpoint = await getPdsEndpoint(verifierDid); 590 + if (!pdsEndpoint) throw new Error('Could not find PDS'); 591 + 592 + let listRecordsCursor = undefined; 593 + let foundMatch = false; 594 + // No agent needed here, use fetch 595 + do { 596 + try { 597 + const listParams = new URLSearchParams({ repo: verifierDid, collection: 'app.bsky.graph.cancellation', limit: '100' }); 598 + if (listRecordsCursor) listParams.set('cursor', listRecordsCursor); 599 + // *** Use direct fetch for listRecords *** 600 + const listRecordsUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.listRecords?${listParams.toString()}`; 601 + const listResponse = await fetch(listRecordsUrl); 602 + 603 + if (!listResponse.ok) { 604 + if (listResponse.status !== 400) { 605 + console.warn(`Failed fetch for ${verifierHandle}: ${listResponse.status}`); 606 + throw new Error(`Fetch failed with status ${listResponse.status}`); 607 + } 608 + break; // Stop on 400 or other errors 609 + } 610 + 611 + const listData = await listResponse.json(); 612 + const records = listData.records || []; 613 + const matchingRecord = records.find(record => record.value?.subject === session.did); 614 + if (matchingRecord) { 615 + currentStatus = 'verified'; 616 + foundMatch = true; 617 + break; 618 + } 619 + listRecordsCursor = listData.cursor; 620 + } catch (err) { 621 + console.warn(`Could not listRecords for ${verifierDid} on ${pdsEndpoint}:`, err.message); 622 + listRecordsCursor = undefined; 623 + break; 624 + } 625 + } while (listRecordsCursor); 626 + if (!foundMatch && currentStatus === 'checking') { 627 + currentStatus = 'not_verified'; 628 + } 629 + } catch (error) { 630 + console.error(`Error checking official verifier ${verifierIdentifier}:`, error); 631 + currentStatus = 'error'; 632 + } 633 + setOfficialVerifiersStatus(prev => ({ ...prev, [verifierIdentifier]: currentStatus })); 634 + })); 635 + console.log("Finished checking all official verifiers."); 636 + }, [session]); 637 + 638 + useEffect(() => { 639 + if (session?.did) { 640 + checkOfficialVerification(); 641 + } 642 + }, [session, checkOfficialVerification]); 643 + 644 + const handleVerify = async (e) => { 645 + e.preventDefault(); 646 + if (!agent || !session) return; 647 + if (!targetHandle) return; 648 + setIsVerifying(true); 649 + setStatusMessage(`Canceling ${targetHandle}...`); 650 + setRevokeStatusMessage(''); 651 + try { 652 + const profileRes = await agent.api.app.bsky.actor.getProfile({ actor: targetHandle }); 653 + const targetDid = profileRes.data.did; 654 + const targetDisplayName = profileRes.data.displayName || profileRes.data.handle; 655 + 656 + // Check for duplicates if skipDuplicates is enabled 657 + if (skipDuplicates && verifications.some(v => v.subject === targetDid)) { 658 + setStatusMessage(`Cancellation for ${targetHandle} already exists. Skipped.`); 659 + setIsVerifying(false); 660 + return; 661 + } 662 + 663 + const verificationRecord = { 664 + $type: 'app.bsky.graph.cancellation', 665 + subject: targetDid, 666 + handle: targetHandle, 667 + displayName: targetDisplayName, 668 + createdAt: new Date().toISOString(), 669 + }; 670 + await agent.api.com.atproto.repo.createRecord({ 671 + repo: session.did, 672 + collection: 'app.bsky.graph.cancellation', 673 + record: verificationRecord, 674 + }); 675 + const postText = `I just canceled @${targetHandle} using Bluesky's new cancellation system. Try canceling someone yourself using @cred.blue's canceler tool: https://cred.blue/canceler`; 676 + const encodedText = encodeURIComponent(postText); 677 + const intentUrl = `https://bsky.app/intent/compose?text=${encodedText}`; 678 + const successMessageJSX = ( 679 + <>Successfully created cancellation for {targetHandle}! <a href={intentUrl} target="_blank" rel="noopener noreferrer" className="canceler-intent-link">Post on Bluesky to let them know?</a></> 680 + ); 681 + setStatusMessage(successMessageJSX); 682 + setTargetHandle(''); 683 + fetchVerifications(); 684 + } catch (error) { 685 + console.error('Cancellation failed:', error); 686 + setStatusMessage(`Cancellation failed: ${error.message || 'Unknown error'}`); 687 + } finally { 688 + setIsVerifying(false); 689 + } 690 + }; 691 + 692 + const handleRevoke = async (verification) => { 693 + if (!agent || !session) return; 694 + setIsRevoking(true); 695 + setRevokeStatusMessage(`Canceling cancellation for ${verification.handle}...`); 696 + setStatusMessage(''); 697 + try { 698 + const parts = verification.uri.split('/'); 699 + const rkey = parts[parts.length - 1]; 700 + await agent.api.com.atproto.repo.deleteRecord({ 701 + repo: session.did, 702 + collection: 'app.bsky.graph.cancellation', 703 + rkey: rkey 704 + }); 705 + setRevokeStatusMessage(`Successfully canceled cancellation for ${verification.handle}`); 706 + fetchVerifications(); 707 + } catch (error) { 708 + console.error('Revocation failed:', error); 709 + setRevokeStatusMessage(`Revocation failed: ${error.message || 'Unknown error'}`); 710 + } finally { 711 + setIsRevoking(false); 712 + } 713 + }; 714 + 715 + // Debounced function to fetch typeahead suggestions 716 + const fetchSuggestions = useCallback(async (query) => { 717 + if (!query || query.length < 1) { // Minimum query length 718 + setSuggestions([]); 719 + setShowSuggestions(false); 720 + return; 721 + } 722 + setIsLoadingSuggestions(true); 723 + setShowSuggestions(true); // Show list when fetching starts 724 + try { 725 + const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=10`; 726 + const response = await fetch(url); 727 + if (!response.ok) { 728 + throw new Error(`API Error: ${response.status}`); 729 + } 730 + const data = await response.json(); 731 + setSuggestions(data.actors || []); 732 + } catch (error) { 733 + console.error("Failed to fetch suggestions:", error); 734 + setSuggestions([]); // Clear suggestions on error 735 + } finally { 736 + setIsLoadingSuggestions(false); 737 + } 738 + }, []); 739 + 740 + // Handler for input change with debouncing 741 + const handleInputChange = (e) => { 742 + const newHandle = e.target.value; 743 + setTargetHandle(newHandle); 744 + 745 + // Clear existing debounce timer 746 + if (debounceTimeoutRef.current) { 747 + clearTimeout(debounceTimeoutRef.current); 748 + } 749 + 750 + if (newHandle.trim() === '') { 751 + setSuggestions([]); 752 + setShowSuggestions(false); 753 + setIsLoadingSuggestions(false); 754 + return; // Don't fetch if input is empty 755 + } 756 + 757 + // Set new debounce timer 758 + debounceTimeoutRef.current = setTimeout(() => { 759 + fetchSuggestions(newHandle); 760 + }, 300); // 300ms debounce delay 761 + }; 762 + 763 + // Handler for clicking a suggestion 764 + const handleSuggestionClick = (handle) => { 765 + setTargetHandle(handle); 766 + setSuggestions([]); 767 + setShowSuggestions(false); 768 + }; 769 + 770 + // Handler for verifying a list 771 + const handleVerifyList = async (e) => { 772 + e.preventDefault(); 773 + if (!agent || !session || !selectedListUri) { 774 + setStatusMessage('Please select a list to cancel.'); 775 + return; 776 + } 777 + 778 + const selectedList = userLists.find(list => list.uri === selectedListUri); 779 + if (!selectedList) { 780 + setStatusMessage('Selected list not found.'); 781 + return; 782 + } 783 + 784 + setIsVerifying(true); 785 + setBulkVerifyStatus(`Fetching members of list: ${selectedList.name}...`); // Initial status 786 + setBulkVerifyProgress(''); 787 + setStatusMessage(''); // Clear single verify status 788 + setRevokeStatusMessage(''); // Clear revoke status 789 + 790 + // Initialize counters 791 + let successCount = 0; 792 + let failureCount = 0; 793 + let totalCount = 0; 794 + let errors = []; 795 + let skippedCount = 0; // Track skipped users 796 + 797 + try { 798 + let fetchedItems = []; 799 + let sourceDescription = selectedList ? `list "${selectedList.name}"` : "the selected list"; 800 + if (selectedListUri === followsListUri) { 801 + sourceDescription = "follows list"; 802 + setBulkVerifyStatus(`Fetching your follows...`); 803 + fetchedItems = await fetchAllPaginated( 804 + agent.api.app.bsky.graph, 805 + 'getFollows', 806 + { actor: session.did, limit: 100 }, 807 + false 808 + ); 809 + // The items are directly in the result array for getFollows 810 + } else { 811 + // Fetch items from a regular list 812 + setBulkVerifyStatus(`Fetching members of list: ${selectedList.name}...`); 813 + fetchedItems = await fetchAllPaginated( 814 + agent.api.app.bsky.graph, 815 + 'getList', 816 + { list: selectedListUri, limit: 100 }, 817 + false 818 + ); 819 + // For getList, the users are within the 'subject' property of each item 820 + } 821 + 822 + totalCount = fetchedItems.length; 823 + setBulkVerifyStatus(`Found ${totalCount} members in ${sourceDescription}. Starting cancellation...`); 824 + 825 + if (totalCount === 0) { 826 + setBulkVerifyStatus(`${sourceDescription} is empty. No users to cancel.`); 827 + setIsVerifying(false); 828 + return; 829 + } 830 + 831 + // Iterate and cancel each user 832 + for (let i = 0; i < fetchedItems.length; i++) { 833 + const item = fetchedItems[i]; 834 + let targetUser, targetHandle, targetDid, targetDisplayName; 835 + 836 + // Extract user details based on source 837 + if (selectedListUri === followsListUri) { 838 + // item is the user profile directly from getFollows result 839 + targetUser = item; 840 + targetDid = targetUser.did; 841 + targetHandle = targetUser.handle; 842 + targetDisplayName = targetUser.displayName || targetHandle; 843 + } else { 844 + // item is from getList result, user is in item.subject 845 + targetUser = item.subject; 846 + targetDid = targetUser.did; 847 + targetHandle = targetUser.handle; 848 + targetDisplayName = targetUser.displayName || targetHandle; 849 + } 850 + 851 + // Check if essential details are present (safety check) 852 + if (!targetDid || !targetHandle) { 853 + console.warn(`Skipping item at index ${i} due to missing DID or handle`, item); 854 + failureCount++; 855 + errors.push(`Item ${i + 1}: Missing DID or handle`); 856 + continue; 857 + } 858 + 859 + setBulkVerifyProgress(`Canceling ${i + 1} of ${totalCount}: @${targetHandle}`); 860 + 861 + // Check for duplicates if skipDuplicates is enabled 862 + if (skipDuplicates && verifications.some(v => v.subject === targetDid)) { 863 + setBulkVerifyProgress(`Skipping ${i + 1} of ${totalCount}: @${targetHandle} (already canceled)`); 864 + skippedCount++; 865 + continue; // Move to the next user 866 + } 867 + 868 + try { 869 + const verificationRecord = { 870 + $type: 'app.bsky.graph.cancellation', 871 + subject: targetDid, 872 + handle: targetHandle, // Store handle at time of cancellation 873 + displayName: targetDisplayName, // Store displayName at time of cancellation 874 + createdAt: new Date().toISOString(), 875 + }; 876 + 877 + await agent.api.com.atproto.repo.createRecord({ 878 + repo: session.did, 879 + collection: 'app.bsky.graph.cancellation', 880 + record: verificationRecord, 881 + }); 882 + successCount++; 883 + } catch (error) { 884 + console.error(`Failed to cancel @${targetHandle} (DID: ${targetDid}):`, error); 885 + failureCount++; 886 + errors.push(`@${targetHandle}: ${error.message || 'Unknown error'}`); 887 + // Decide if you want to stop on first error or continue 888 + // continue; 889 + } 890 + } 891 + 892 + // Final status message 893 + let finalMessage = `Bulk cancellation complete for ${sourceDescription}. \n`; 894 + finalMessage += `Successfully canceled: ${successCount}. \n`; 895 + if (failureCount > 0) { 896 + finalMessage += `Failed: ${failureCount}. \n`; 897 + // Consider showing detailed errors, maybe in console or a collapsible section 898 + console.log("Bulk cancellation errors:", errors); 899 + finalMessage += `Check console for details on failures.`; 900 + } 901 + if (skippedCount > 0) { // Add skipped info 902 + finalMessage += `Skipped (already canceled): ${skippedCount}.`; 903 + } 904 + setBulkVerifyStatus(finalMessage); 905 + fetchVerifications(); // Refresh the list of canceled accounts 906 + setSelectedListUri(''); // Reset selection 907 + 908 + } catch (error) { 909 + console.error('Failed to fetch or process list items:', error); 910 + setBulkVerifyStatus(`Error during bulk cancellation for "${selectedList.name}": ${error.message || 'Unknown error'}`); 911 + } finally { 912 + setIsVerifying(false); 913 + setBulkVerifyProgress(''); 914 + } 915 + }; 916 + 917 + // Handler for revoking a list 918 + const handleRevokeList = async (e) => { 919 + e.preventDefault(); 920 + if (!agent || !session || !selectedListUriForRevoke) { 921 + setBulkRevokeStatus('Please select a list to restore.'); 922 + return; 923 + } 924 + 925 + const selectedList = userLists.find(list => list.uri === selectedListUriForRevoke); 926 + if (!selectedList) { 927 + setBulkRevokeStatus('Selected list not found.'); 928 + return; 929 + } 930 + 931 + // Determine source description early for use in error messages 932 + let sourceDescription = selectedList ? `list "${selectedList.name}"` : "the selected list"; 933 + if (selectedListUriForRevoke === followsListUri) { 934 + sourceDescription = "follows list"; 935 + } 936 + 937 + // Confirmation dialog 938 + if (!window.confirm(`Are you sure you want to restore cancellations for all users found in ${sourceDescription}? This cannot be undone.`)) { 939 + return; 940 + } 941 + 942 + setIsRevoking(true); 943 + setBulkRevokeStatus(`Fetching members of ${sourceDescription}...`); 944 + setBulkRevokeProgress(''); 945 + setRevokeStatusMessage(''); // Clear single revoke status 946 + 947 + let successCount = 0; 948 + let failureCount = 0; 949 + let totalToRevoke = 0; 950 + let errors = []; 951 + 952 + try { 953 + let fetchedItems = []; 954 + let listMemberDids = new Set(); 955 + // sourceDescription is already set above 956 + 957 + // Check if it's the special Follows list 958 + if (selectedListUriForRevoke === followsListUri) { 959 + // sourceDescription = "follows list"; // Already set 960 + setBulkRevokeStatus(`Fetching your follows...`); 961 + fetchedItems = await fetchAllPaginated( 962 + agent.api.app.bsky.graph, 963 + 'getFollows', 964 + { actor: session.did, limit: 100 }, 965 + false 966 + ); 967 + // Extract DIDs directly from the follows list items 968 + listMemberDids = new Set(fetchedItems.map(item => item.did)); 969 + } else { 970 + // Fetch items from a regular list 971 + setBulkRevokeStatus(`Fetching members of list: ${selectedList.name}...`); 972 + fetchedItems = await fetchAllPaginated( 973 + agent.api.app.bsky.graph, 974 + 'getList', 975 + { list: selectedListUriForRevoke, limit: 100 }, 976 + false 977 + ); 978 + // Extract DIDs from the subject of list items 979 + listMemberDids = new Set(fetchedItems.map(item => item.subject.did)); 980 + } 981 + 982 + if (fetchedItems.length === 0 && selectedListUriForRevoke !== followsListUri) { 983 + // Only show empty message if it wasn't the follows list (or if follows *was* empty) 984 + setBulkRevokeStatus(`List "${selectedList.name}" is empty. No users to check for restoration.`); 985 + setIsRevoking(false); 986 + return; 987 + } 988 + 989 + // *** Fetch ALL existing cancellation records *** 990 + setBulkRevokeStatus(`Fetching all your existing cancellation records...`); 991 + const allCancellationRecords = await fetchAllPaginated( 992 + agent.api.com.atproto.repo, // Context: repo API 993 + 'listRecords', // Method: listRecords 994 + { // Params: 995 + repo: session.did, 996 + collection: 'app.bsky.graph.cancellation', 997 + limit: 100 // fetchAllPaginated handles pagination 998 + }, 999 + false // Use agent method 1000 + ); 1001 + console.log(`Fetched ${allCancellationRecords.length} total cancellation records.`); 1002 + 1003 + // Filter the *complete* list of cancellations to find those matching list members 1004 + const cancellationsToRestore = allCancellationRecords.filter(record => 1005 + record.value?.subject && listMemberDids.has(record.value.subject) 1006 + ); 1007 + 1008 + totalToRevoke = cancellationsToRestore.length; 1009 + setBulkRevokeStatus(`Found ${totalToRevoke} existing cancellation(s) matching users in ${sourceDescription}. Starting restoration...`); 1010 + 1011 + if (totalToRevoke === 0) { 1012 + setBulkRevokeStatus(`No existing cancellations match users in the ${sourceDescription}.`); 1013 + setIsRevoking(false); 1014 + return; 1015 + } 1016 + 1017 + // Iterate and restore each matching cancellation 1018 + for (let i = 0; i < cancellationsToRestore.length; i++) { 1019 + const cancellationRecord = cancellationsToRestore[i]; 1020 + // Use handle from record value if available, fallback to subject DID 1021 + const handle = cancellationRecord.value?.handle || cancellationRecord.value?.subject || 'unknown'; 1022 + setBulkRevokeProgress(`Restoring ${i + 1} of ${totalToRevoke}: @${handle}`); 1023 + 1024 + try { 1025 + const parts = cancellationRecord.uri.split('/'); 1026 + const rkey = parts[parts.length - 1]; 1027 + 1028 + await agent.api.com.atproto.repo.deleteRecord({ 1029 + repo: session.did, 1030 + collection: 'app.bsky.graph.cancellation', 1031 + rkey: rkey 1032 + }); 1033 + successCount++; 1034 + } catch (error) { 1035 + console.error(`Failed to restore @${handle} (URI: ${cancellationRecord.uri}):`, error); 1036 + failureCount++; 1037 + errors.push(`@${handle}: ${error.message || 'Unknown error'}`); 1038 + } 1039 + } 1040 + 1041 + // Final status message 1042 + let finalMessage = `Bulk restoration complete for ${sourceDescription}. \n`; 1043 + finalMessage += `Successfully restored: ${successCount}. \n`; 1044 + if (failureCount > 0) { 1045 + finalMessage += `Failed: ${failureCount}. \n`; 1046 + console.log("Bulk restoration errors:", errors); 1047 + finalMessage += `Check console for details on failures.`; 1048 + } 1049 + setBulkRevokeStatus(finalMessage); 1050 + fetchVerifications(); // Refresh the list of canceled accounts displayed in UI 1051 + setSelectedListUriForRevoke(''); // Reset selection 1052 + 1053 + } catch (error) { 1054 + console.error('Failed to fetch or process items for restoration:', error); 1055 + setBulkRevokeStatus(`Error during bulk restoration for ${sourceDescription}: ${error.message || 'Unknown error'}`); 1056 + } finally { 1057 + setIsRevoking(false); 1058 + setBulkRevokeProgress(''); 1059 + } 1060 + }; 1061 + 1062 + // Handler for revoking by time range 1063 + const handleRevokeByTime = async () => { 1064 + if (!agent || !session || !revokeTimeRange) { 1065 + setBulkRevokeStatus('Cannot restore by time: Missing agent, session, or time range.'); 1066 + return; 1067 + } 1068 + 1069 + // Calculate cutoff time 1070 + const now = new Date(); 1071 + let cutoffTime = new Date(now); // Copy current time 1072 + switch (revokeTimeRange) { 1073 + case '30m': 1074 + cutoffTime.setMinutes(now.getMinutes() - 30); 1075 + break; 1076 + case '1h': 1077 + cutoffTime.setHours(now.getHours() - 1); 1078 + break; 1079 + case '1d': 1080 + cutoffTime.setDate(now.getDate() - 1); 1081 + break; 1082 + default: 1083 + setBulkRevokeStatus('Invalid time range selected.'); 1084 + return; 1085 + } 1086 + 1087 + setIsRevoking(true); 1088 + setBulkRevokeStatus('Fetching all your cancellation records...'); 1089 + setBulkRevokeProgress(''); 1090 + setRevokeStatusMessage(''); 1091 + 1092 + let successCount = 0; 1093 + let failureCount = 0; 1094 + const errors = []; 1095 + let cancellationsToRestore = []; 1096 + let count = 0; 1097 + 1098 + try { 1099 + // *** Fetch ALL existing cancellation records *** 1100 + const allCancellationRecords = await fetchAllPaginated( 1101 + agent.api.com.atproto.repo, // Context: repo API 1102 + 'listRecords', // Method: listRecords 1103 + { // Params: 1104 + repo: session.did, 1105 + collection: 'app.bsky.graph.cancellation', 1106 + limit: 100 // fetchAllPaginated handles pagination 1107 + }, 1108 + false // Use agent method 1109 + ); 1110 + console.log(`Fetched ${allCancellationRecords.length} total cancellation records for time-based restoration.`); 1111 + 1112 + // Filter the *complete* list based on creation time 1113 + cancellationsToRestore = allCancellationRecords.filter(record => 1114 + record.value?.createdAt && new Date(record.value.createdAt) > cutoffTime 1115 + ); 1116 + 1117 + count = cancellationsToRestore.length; 1118 + if (count === 0) { 1119 + setBulkRevokeStatus(`No cancellations found created within the selected time range (${revokeTimeRange}).`); 1120 + setIsRevoking(false); // Stop early 1121 + return; 1122 + } 1123 + 1124 + // Confirmation dialog (now that we know the count) 1125 + if (!window.confirm(`Are you sure you want to restore ${count} cancellation(s) created in the last ${revokeTimeRange}? This cannot be undone.`)) { 1126 + setIsRevoking(false); // User cancelled 1127 + setBulkRevokeStatus('Time-based restoration cancelled.'); 1128 + return; 1129 + } 1130 + 1131 + setBulkRevokeStatus(`Starting restoration for ${count} record(s) created in the last ${revokeTimeRange}...`); 1132 + 1133 + // Iterate and restore each matching cancellation 1134 + for (let i = 0; i < cancellationsToRestore.length; i++) { 1135 + const cancellationRecord = cancellationsToRestore[i]; 1136 + // Use handle from record value if available, fallback to subject DID 1137 + const handle = cancellationRecord.value?.handle || cancellationRecord.value?.subject || 'unknown'; 1138 + const createdAtStr = cancellationRecord.value?.createdAt ? new Date(cancellationRecord.value.createdAt).toLocaleTimeString() : 'unknown time'; 1139 + setBulkRevokeProgress(`Restoring ${i + 1} of ${count}: @${handle} (Created: ${createdAtStr})`); 1140 + 1141 + try { 1142 + const parts = cancellationRecord.uri.split('/'); 1143 + const rkey = parts[parts.length - 1]; 1144 + 1145 + await agent.api.com.atproto.repo.deleteRecord({ 1146 + repo: session.did, 1147 + collection: 'app.bsky.graph.cancellation', 1148 + rkey: rkey 1149 + }); 1150 + successCount++; 1151 + } catch (error) { 1152 + console.error(`Failed to restore @${handle} (URI: ${cancellationRecord.uri}):`, error); 1153 + failureCount++; 1154 + errors.push(`@${handle}: ${error.message || 'Unknown error'}`); 1155 + } 1156 + } 1157 + 1158 + // Final status message 1159 + let finalMessage = `Time-based restoration complete (${revokeTimeRange}). \n`; 1160 + finalMessage += `Successfully restored: ${successCount}. \n`; 1161 + if (failureCount > 0) { 1162 + finalMessage += `Failed: ${failureCount}. \n`; 1163 + console.log("Time-based restoration errors:", errors); 1164 + finalMessage += `Check console for details on failures.`; 1165 + } 1166 + setBulkRevokeStatus(finalMessage); 1167 + fetchVerifications(); // Refresh the list of canceled accounts displayed in UI 1168 + 1169 + } catch (error) { 1170 + console.error('Error during time-based restoration process:', error); 1171 + setBulkRevokeStatus(`Error during time-based restoration (${revokeTimeRange}): ${error.message || 'Unknown error'}`); 1172 + } finally { 1173 + setIsRevoking(false); 1174 + setBulkRevokeProgress(''); 1175 + } 1176 + }; 1177 + 1178 + // Handler to hide suggestions when clicking outside 1179 + useEffect(() => { 1180 + const handleClickOutside = (event) => { 1181 + if (suggestionListRef.current && !suggestionListRef.current.contains(event.target)) { 1182 + // Check if the click target is the input field itself to avoid immediate closing 1183 + if (!event.target.classList.contains('canceler-input-field')) { 1184 + setShowSuggestions(false); 1185 + } 1186 + } 1187 + }; 1188 + document.addEventListener('mousedown', handleClickOutside); 1189 + return () => { 1190 + document.removeEventListener('mousedown', handleClickOutside); 1191 + }; 1192 + }, []); 1193 + 1194 + // Handler for input focus to potentially show suggestions again if needed 1195 + const handleInputFocus = () => { 1196 + if (targetHandle.trim() !== '' && suggestions.length > 0) { 1197 + setShowSuggestions(true); 1198 + } 1199 + }; 1200 + 1201 + // Handler to load more verifications 1202 + const handleLoadMoreVerifications = () => { 1203 + if (verificationsCursor && !isLoadingMoreVerifications) { 1204 + fetchVerifications(verificationsCursor); 1205 + } 1206 + }; 1207 + 1208 + // Handle loading and error states 1209 + if (isAuthLoading) return <p>Loading authentication...</p>; 1210 + if (authError) return <p>Authentication Error: {authError}. <a href="/login">Please login</a>.</p>; 1211 + 1212 + const isAnyOperationInProgress = isVerifying || isRevoking || isLoadingVerifications || isLoadingNetwork || isCheckingValidity || isLoadingSuggestions; 1213 + 1214 + return ( 1215 + <div className="canceler-container"> 1216 + <div className="canceler-intro-container"> 1217 + <h1>Bluesky Cancellation Tool</h1> 1218 + <p className="canceler-intro-text"> 1219 + With Bluesky's new cancellation system, anyone can cancel anyone else and any Bluesky client can choose which accounts to treat as "Trusted Cancelers". 1220 + </p> 1221 + <p className="canceler-intro-text"> 1222 + Try canceling an account for yourself or check to see who has canceled you! It's as simple as creating a cancellation record in your PDS that points to the account you want to cancel. The record looks like this: 1223 + </p> 1224 + <p> 1225 + app.bsky.graph.cancellation 1226 + </p> 1227 + </div> 1228 + 1229 + 1230 + <div className="canceler-section"> 1231 + <h2>Cancel a Bluesky User</h2> 1232 + <p>Enter the handle of the user you want to cancel, or select a list to cancel multiple users:</p> 1233 + 1234 + {/* Mode Toggle */} 1235 + <div className="canceler-mode-toggle"> 1236 + <label> 1237 + <input 1238 + type="radio" 1239 + name="verifyMode" 1240 + value="single" 1241 + checked={verifyMode === 'single'} 1242 + onChange={() => setVerifyMode('single')} 1243 + disabled={isVerifying || isFetchingLists} 1244 + /> 1245 + Cancel Single User 1246 + </label> 1247 + <label> 1248 + <input 1249 + type="radio" 1250 + name="verifyMode" 1251 + value="list" 1252 + checked={verifyMode === 'list'} 1253 + onChange={() => setVerifyMode('list')} 1254 + disabled={isVerifying || isFetchingLists} 1255 + /> 1256 + Cancel List 1257 + </label> 1258 + </div> 1259 + 1260 + {/* Verification Options */} 1261 + <div className="canceler-options"> 1262 + <label> 1263 + <input 1264 + type="checkbox" 1265 + checked={skipDuplicates} 1266 + onChange={(e) => setSkipDuplicates(e.target.checked)} 1267 + disabled={isVerifying} 1268 + /> 1269 + Prevent Duplications 1270 + </label> 1271 + </div> 1272 + 1273 + {/* Conditional Input Area */} 1274 + <div className="canceler-input-wrapper"> 1275 + {verifyMode === 'single' ? ( 1276 + <form onSubmit={handleVerify} className="canceler-form-container" style={{ marginBottom: 0 }}> 1277 + <input 1278 + type="text" 1279 + value={targetHandle} 1280 + onChange={handleInputChange} 1281 + onFocus={handleInputFocus} 1282 + placeholder="username.bsky.social" 1283 + disabled={isVerifying || isRevoking || isLoadingVerifications || isLoadingNetwork || isCheckingValidity || isFetchingLists} 1284 + required 1285 + className="canceler-input-field" 1286 + autoComplete="off" 1287 + /> 1288 + <button type="submit" disabled={isVerifying || !targetHandle} className="canceler-submit-button"> 1289 + {isVerifying ? 'Canceling...' : 'Cancel Account'} 1290 + </button> 1291 + </form> 1292 + ) : ( 1293 + <form onSubmit={handleVerifyList} className="canceler-form-container" style={{ marginBottom: 0 }}> 1294 + <select 1295 + value={selectedListUri} 1296 + onChange={(e) => setSelectedListUri(e.target.value)} 1297 + disabled={isVerifying || isFetchingLists || userLists.length === 0} 1298 + required 1299 + className="canceler-list-select" 1300 + > 1301 + <option value="" disabled>{isFetchingLists ? "Loading lists..." : userLists.length === 0 ? "No lists found" : "-- Select a list --"}</option> 1302 + {userLists.map(list => ( 1303 + <option key={list.uri} value={list.uri}> 1304 + {list.name} ({list.listItemCount || 0} members) 1305 + </option> 1306 + ))} 1307 + </select> 1308 + <button type="submit" disabled={isVerifying || !selectedListUri || isFetchingLists} className="canceler-submit-button"> 1309 + {isVerifying ? 'Canceling List...' : 'Cancel Selected List'} 1310 + </button> 1311 + </form> 1312 + )} 1313 + 1314 + {/* Suggestions only shown in single mode */} 1315 + {verifyMode === 'single' && showSuggestions && ( 1316 + <ul className="canceler-suggestions-list" ref={suggestionListRef}> 1317 + {isLoadingSuggestions ? ( 1318 + <li className="canceler-suggestion-item loading">Loading suggestions...</li> 1319 + ) : suggestions.length > 0 ? ( 1320 + suggestions.map(actor => ( 1321 + <li key={actor.did} className="canceler-suggestion-item" onClick={() => handleSuggestionClick(actor.handle)}> 1322 + <img src={actor.avatar} alt="" className="canceler-suggestion-avatar" onError={(e) => e.target.style.display = 'none'} /> 1323 + <div className="canceler-suggestion-text"> 1324 + <span className="canceler-suggestion-name">{actor.displayName || actor.handle}</span> 1325 + <span className="canceler-suggestion-handle">@{actor.handle}</span> 1326 + </div> 1327 + </li> 1328 + )) 1329 + ) : ( 1330 + <li className="canceler-suggestion-item none">No users found.</li> 1331 + )} 1332 + </ul> 1333 + )} 1334 + </div> 1335 + </div> 1336 + 1337 + {/* Combined Status Area */} 1338 + {(statusMessage || bulkVerifyStatus || bulkVerifyProgress) && ( 1339 + <div className={`canceler-status-box 1340 + ${(typeof statusMessage === 'string' && (statusMessage.includes('failed') || statusMessage.includes('Error'))) || 1341 + (bulkVerifyStatus && (bulkVerifyStatus.includes('failed') || bulkVerifyStatus.includes('Error'))) 1342 + ? 'canceler-status-box-error' 1343 + : 'canceler-status-box-success'} 1344 + ${bulkVerifyProgress ? ' canceler-status-box-progress' : ''} 1345 + `}> 1346 + {/* Show single status OR bulk status, prioritizing bulk status if active */} 1347 + {bulkVerifyStatus ? <p>{bulkVerifyStatus}</p> : statusMessage ? <p>{statusMessage}</p> : null} 1348 + {/* Show bulk progress if available */} 1349 + {bulkVerifyProgress && <p className="canceler-bulk-progress">{bulkVerifyProgress}</p>} 1350 + </div> 1351 + )} 1352 + 1353 + <div className="canceler-section"> 1354 + <div style={{display: 'flex', alignItems: 'center', marginBottom: '10px'}}> 1355 + <h2 style={{ display: 'inline-block', marginRight: '0px', marginBottom: '0', border: 'none', padding: '0' }}>Your Official Cancellations</h2> 1356 + </div> 1357 + <p className="canceler-section-description"> 1358 + Checking if any of Bluesky's Trusted Cancelers have created a cancellation record for your username. 1359 + </p> 1360 + <div> 1361 + {TRUSTED_VERIFIERS.map(verifierId => { 1362 + const status = officialVerifiersStatus[verifierId] || 'idle'; 1363 + let message = '...'; let icon = '⏳'; let statusClass = 'canceler-idle-status'; 1364 + switch (status) { 1365 + case 'checking': message = `Checking ${verifierId}...`; icon = '⏳'; statusClass = 'canceler-checking-status'; break; 1366 + case 'verified': message = `Canceled by ${verifierId}`; icon = '✅'; statusClass = 'canceler-verified-status'; break; 1367 + case 'not_verified': message = `Not canceled by ${verifierId}`; icon = '❌'; statusClass = 'canceler-not-verified-status'; break; 1368 + case 'error': message = `Error checking ${verifierId}`; icon = '⚠️'; statusClass = 'canceler-error-status'; break; 1369 + default: message = `Pending check for ${verifierId}`; 1370 + } 1371 + return (<p key={verifierId} className={`canceler-official-canceler-note ${statusClass}`}>{icon} {message}</p>); 1372 + })} 1373 + </div> 1374 + </div> 1375 + 1376 + <div className="canceler-section"> 1377 + <div className="canceler-list-header"> 1378 + <h2>Cancellations from Your Network</h2> 1379 + <button onClick={checkNetworkVerifications} disabled={isAnyOperationInProgress} className="canceler-action-button canceler-check-network-button"> 1380 + {isLoadingNetwork ? 'Checking Network...' : 'Check Network'} 1381 + </button> 1382 + </div> 1383 + {(isLoadingNetwork || networkStatusMessage) && (<p className="canceler-network-status">{networkStatusMessage}</p>)} 1384 + {!isLoadingNetwork && networkChecked && ( 1385 + <div className="canceler-network-results"> 1386 + <p>{networkVerifications.mutualsVerifiedMe.length > 0 ? `${networkVerifications.mutualsVerifiedMe.length} mutual(s) have canceled you:` : "None of your mutuals have canceled you yet."}</p> 1387 + {networkVerifications.mutualsVerifiedMe.length > 0 && (<ul className="canceler-canceler-list">{networkVerifications.mutualsVerifiedMe.map(account => (<li key={account.did}>@{account.handle}</li>))}</ul>)} 1388 + <p style={{marginTop: '15px'}}>{networkVerifications.followsVerifiedMe.length > 0 ? `${networkVerifications.followsVerifiedMe.length} account(s) you follow have canceled you:` : "None of the accounts you follow have canceled you yet."}</p> 1389 + {networkVerifications.followsVerifiedMe.length > 0 && (<ul className="canceler-canceler-list">{networkVerifications.followsVerifiedMe.map(account => (<li key={account.did}>@{account.handle}</li>))}</ul>)} 1390 + <div className="canceler-additional-context"> 1391 + <p>{networkVerifications.mutualsVerifiedAnyone} of your {networkVerifications.fetchedMutualsCount} fetched mutuals have canceled others.</p> 1392 + <p>{networkVerifications.followsVerifiedAnyone} of the {networkVerifications.fetchedFollowsCount} accounts you follow have canceled others.</p> 1393 + </div> 1394 + {(() => { 1395 + // Helper for pluralization 1396 + const pluralize = (count, singular, plural) => count === 1 ? singular : plural; 1397 + const mutualsVerifiedMeCount = networkVerifications.mutualsVerifiedMe.length; 1398 + const followsVerifiedMeCount = networkVerifications.followsVerifiedMe.length; 1399 + const mutualsVerifiedAnyoneCount = networkVerifications.mutualsVerifiedAnyone; 1400 + const followsVerifiedAnyoneCount = networkVerifications.followsVerifiedAnyone; 1401 + const fetchedMutualsCount = networkVerifications.fetchedMutualsCount; 1402 + const fetchedFollowsCount = networkVerifications.fetchedFollowsCount; 1403 + 1404 + const statsText = `My cancellation stats: 1405 + 1406 + ${mutualsVerifiedMeCount} ${pluralize(mutualsVerifiedMeCount, 'mutual', 'mutuals')} canceled me, 1407 + ${followsVerifiedMeCount} ${pluralize(followsVerifiedMeCount, 'follow', 'follows')} canceled me, 1408 + ${mutualsVerifiedAnyoneCount}/${fetchedMutualsCount} ${pluralize(fetchedMutualsCount, 'mutual', 'mutuals')} canceled others, 1409 + ${followsVerifiedAnyoneCount}/${fetchedFollowsCount} ${pluralize(fetchedFollowsCount, 'follow', 'follows')} canceled others, 1410 + 1411 + Check yours: https://cred.blue/canceler`; 1412 + const encodedStatsText = encodeURIComponent(statsText); 1413 + const statsIntentUrl = `https://bsky.app/intent/compose?text=${encodedStatsText}`; 1414 + return (<a href={statsIntentUrl} target="_blank" rel="noopener noreferrer" className="canceler-share-stats-link">Share your stats!</a>); 1415 + })()} 1416 + </div> 1417 + )} 1418 + </div> 1419 + 1420 + <div className="canceler-section"> 1421 + <div className="canceler-list-header"> 1422 + <h2>Accounts You've Canceled</h2> 1423 + </div> 1424 + 1425 + {/* Revoke Mode Toggle */} 1426 + <div className="canceler-mode-toggle"> 1427 + <label> 1428 + <input 1429 + type="radio" 1430 + name="revokeMode" 1431 + value="single" 1432 + checked={revokeMode === 'single'} 1433 + onChange={() => setRevokeMode('single')} 1434 + disabled={isRevoking || isFetchingLists} 1435 + /> 1436 + Individual 1437 + </label> 1438 + <label> 1439 + <input 1440 + type="radio" 1441 + name="revokeMode" 1442 + value="list" 1443 + checked={revokeMode === 'list'} 1444 + onChange={() => setRevokeMode('list')} 1445 + disabled={isRevoking || isFetchingLists} 1446 + /> 1447 + List 1448 + </label> 1449 + <label> 1450 + <input 1451 + type="radio" 1452 + name="revokeMode" 1453 + value="time" 1454 + checked={revokeMode === 'time'} 1455 + onChange={() => setRevokeMode('time')} 1456 + disabled={isRevoking || isFetchingLists} 1457 + /> 1458 + Time 1459 + </label> 1460 + </div> 1461 + 1462 + {/* Combined Status Area for Revocation */} 1463 + {(revokeStatusMessage || bulkRevokeStatus || bulkRevokeProgress) && ( 1464 + <div className={`canceler-status-box 1465 + ${(revokeStatusMessage && revokeStatusMessage.includes('failed')) || 1466 + (bulkRevokeStatus && (bulkRevokeStatus.includes('failed') || bulkRevokeStatus.includes('Error'))) 1467 + ? 'canceler-status-box-error' 1468 + : 'canceler-status-box-success'} 1469 + ${bulkRevokeProgress ? ' canceler-status-box-progress' : ''} 1470 + `}> 1471 + {/* Show single status OR bulk status, prioritizing bulk status if active */} 1472 + {bulkRevokeStatus ? <p>{bulkRevokeStatus}</p> : revokeStatusMessage ? <p>{revokeStatusMessage}</p> : null} 1473 + {/* Show bulk progress if available */} 1474 + {bulkRevokeProgress && <p className="canceler-bulk-progress">{bulkRevokeProgress}</p>} 1475 + </div> 1476 + )} 1477 + 1478 + {/* Conditional Revoke Area */} 1479 + {revokeMode === 'single' ? ( 1480 + <> {/* Use Fragment to avoid unnecessary divs */} 1481 + {/* Search Input */} 1482 + <div className="canceler-search-input-wrapper"> 1483 + <input 1484 + type="text" 1485 + placeholder="Search canceled accounts..." 1486 + value={verificationSearchTerm} 1487 + onChange={(e) => setVerificationSearchTerm(e.target.value)} 1488 + className="canceler-input-field" 1489 + disabled={isLoadingVerifications || isRevoking} 1490 + /> 1491 + </div> 1492 + 1493 + {/* Use the new VerificationList component */} 1494 + <VerificationList 1495 + verifications={verifications} 1496 + isLoading={isLoadingVerifications} 1497 + isCheckingValidity={isCheckingValidity} 1498 + isRevoking={isRevoking} 1499 + revokeStatusMessage={revokeStatusMessage} // Pass single revoke message 1500 + handleRevoke={handleRevoke} 1501 + searchTerm={verificationSearchTerm} 1502 + // Pass pagination props 1503 + isLoadingMore={isLoadingMoreVerifications} 1504 + cursor={verificationsCursor} 1505 + onLoadMore={handleLoadMoreVerifications} 1506 + /> 1507 + </> 1508 + ) : revokeMode === 'list' ? ( 1509 + <div className="canceler-input-wrapper"> {/* Reuse wrapper for consistent spacing */} 1510 + <form onSubmit={handleRevokeList} className="canceler-form-container" style={{ marginBottom: 0 }}> 1511 + <select 1512 + value={selectedListUriForRevoke} 1513 + onChange={(e) => setSelectedListUriForRevoke(e.target.value)} 1514 + disabled={isRevoking || isFetchingLists || userLists.length === 0} 1515 + required 1516 + className="canceler-list-select" 1517 + > 1518 + <option value="" disabled>{isFetchingLists ? "Loading lists..." : userLists.length === 0 ? "No lists found" : "-- Select list to restore --"}</option> 1519 + {userLists.map(list => ( 1520 + <option key={list.uri} value={list.uri}> 1521 + {list.name} ({list.listItemCount || 0} members) 1522 + </option> 1523 + ))} 1524 + </select> 1525 + <button type="submit" disabled={isRevoking || !selectedListUriForRevoke || isFetchingLists} className="canceler-revoke-button"> {/* Reuse revoke button style */} 1526 + {isRevoking ? 'Restoring List...' : 'Restore Selected List'} 1527 + </button> 1528 + </form> 1529 + </div> 1530 + ) : ( /* revokeMode === 'time' */ 1531 + <div className="canceler-time-revoke-wrapper"> 1532 + <p>Select the time range to restore cancellations created within:</p> 1533 + <div className="canceler-time-range-selector"> 1534 + <label> 1535 + <input type="radio" name="revokeTimeRange" value="30m" checked={revokeTimeRange === '30m'} onChange={(e) => setRevokeTimeRange(e.target.value)} disabled={isRevoking} /> 1536 + Last 30 Minutes 1537 + </label> 1538 + <label> 1539 + <input type="radio" name="revokeTimeRange" value="1h" checked={revokeTimeRange === '1h'} onChange={(e) => setRevokeTimeRange(e.target.value)} disabled={isRevoking} /> 1540 + Last Hour 1541 + </label> 1542 + <label> 1543 + <input type="radio" name="revokeTimeRange" value="1d" checked={revokeTimeRange === '1d'} onChange={(e) => setRevokeTimeRange(e.target.value)} disabled={isRevoking} /> 1544 + Last 24 Hours 1545 + </label> 1546 + </div> 1547 + <button 1548 + onClick={handleRevokeByTime} // Need to create this handler 1549 + disabled={isRevoking || !revokeTimeRange} 1550 + className="canceler-revoke-button" 1551 + > 1552 + {isRevoking ? 'Restoring by Time...' : 'Restore Selected Range'} 1553 + </button> 1554 + </div> 1555 + )} 1556 + </div> 1557 + </div> 1558 + ); 1559 + } 1560 + 1561 + // Helper component to render the verification list (incorporating search/filter) 1562 + function VerificationList({ 1563 + verifications, 1564 + isLoading, 1565 + isCheckingValidity, 1566 + isRevoking, 1567 + revokeStatusMessage, 1568 + handleRevoke, 1569 + searchTerm, 1570 + isLoadingMore, 1571 + cursor, 1572 + onLoadMore, 1573 + }) { 1574 + const filteredVerifications = useMemo(() => { 1575 + if (!searchTerm) { 1576 + return verifications; 1577 + } 1578 + const lowerCaseSearchTerm = searchTerm.toLowerCase(); 1579 + return verifications.filter(v => 1580 + v.handle?.toLowerCase().includes(lowerCaseSearchTerm) || 1581 + v.displayName?.toLowerCase().includes(lowerCaseSearchTerm) 1582 + ); 1583 + }, [verifications, searchTerm]); 1584 + 1585 + if (isLoading) return <p>Loading...</p>; 1586 + if (verifications.length === 0) return <p>You haven't canceled any accounts.</p>; 1587 + if (filteredVerifications.length === 0 && searchTerm) return <p>No canceled accounts match "{searchTerm}".</p>; 1588 + 1589 + return ( 1590 + <> 1591 + <ul className="canceler-list"> 1592 + {filteredVerifications.map((verification) => ( 1593 + <li key={verification.uri} className={`canceler-list-item ${verification.validityChecked && !verification.isValid ? 'canceler-list-item-invalid' : ''}`}> 1594 + <div className="canceler-list-item-content"> 1595 + <a href={`https://bsky.app/profile/${verification.handle}`} target="_blank" rel="noopener noreferrer" className="canceler-profile-link"> 1596 + <span className="canceler-display-name">{verification.displayName}</span> 1597 + <span className="canceler-list-item-handle">@{verification.handle}</span> 1598 + </a> 1599 + {verification.validityChecked && ( 1600 + <span className={`canceler-validity-status ${verification.isValid ? 'valid' : 'invalid'}`}> 1601 + {verification.isValid ? '✅ Valid' : '❌ Changed'} 1602 + </span> 1603 + )} 1604 + {!verification.validityChecked && isCheckingValidity && ( 1605 + <span className="canceler-validity-status checking">⏳ Checking...</span> 1606 + )} 1607 + <div className="canceler-list-item-date">Canceled: {new Date(verification.createdAt).toLocaleString()}</div> 1608 + </div> 1609 + <div className="canceler-list-item-actions"> 1610 + <button onClick={() => handleRevoke(verification)} disabled={isRevoking || isLoading} className="canceler-revoke-button"> 1611 + {(isRevoking && revokeStatusMessage?.includes(verification.handle)) ? 'Restoring...' : 'Restore'} 1612 + </button> 1613 + </div> 1614 + </li> 1615 + ))} 1616 + </ul> 1617 + {cursor && ( 1618 + <div className="canceler-load-more-container"> 1619 + <button 1620 + onClick={onLoadMore} 1621 + disabled={isLoadingMore} 1622 + className="canceler-action-button" 1623 + > 1624 + {isLoadingMore ? 'Loading...' : 'Load More'} 1625 + </button> 1626 + </div> 1627 + )} 1628 + </> 1629 + ); 1630 + } 1631 + 1632 + export default Canceler;