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