the home of serif.blue
1// ==UserScript==
2// @name Bluesky Community Verifications
3// @namespace https://tangled.sh/@dunkirk.sh/serif/verifications
4// @version 0.2.1
5// @description Shows verification badges from trusted community members on Bluesky
6// @author Kieran Klukas
7// @match https://bsky.app/*
8// @grant none
9// @run-at document-end
10// @updateURL https://tangled.sh/@dunkirk.sh/serif/raw/main/bluesky-community-verifications.user.meta.js
11// @downloadURL https://tangled.sh/@dunkirk.sh/serif/raw/main/bluesky-community-verifications.user.js
12// ==/UserScript==
13
14(() => {
15 // Script has already been initialized check
16 if (window.bskyTrustedUsersInitialized) {
17 console.log("Trusted Users script already initialized");
18 return;
19 }
20
21 // Mark script as initialized
22 window.bskyTrustedUsersInitialized = true;
23
24 // Define storage keys
25 const TRUSTED_USERS_STORAGE_KEY = "bsky_trusted_users";
26 const VERIFICATION_CACHE_STORAGE_KEY = "bsky_verification_cache";
27 const CACHE_EXPIRY_TIME = 24 * 60 * 60 * 1000; // 24 hours
28 const BADGE_TYPE_STORAGE_KEY = "bsky_verification_badge_type";
29 const BADGE_COLOR_STORAGE_KEY = "bsky_verification_badge_color";
30
31 // Default badge configuration
32 const DEFAULT_BADGE_TYPE = "checkmark";
33 const DEFAULT_BADGE_COLOR = "#0070ff";
34
35 // Functions to get/set badge configuration
36 const getBadgeType = () => {
37 return localStorage.getItem(BADGE_TYPE_STORAGE_KEY) || DEFAULT_BADGE_TYPE;
38 };
39
40 const getBadgeColor = () => {
41 return localStorage.getItem(BADGE_COLOR_STORAGE_KEY) || DEFAULT_BADGE_COLOR;
42 };
43
44 const saveBadgeType = (type) => {
45 localStorage.setItem(BADGE_TYPE_STORAGE_KEY, type);
46 };
47
48 const saveBadgeColor = (color) => {
49 localStorage.setItem(BADGE_COLOR_STORAGE_KEY, color);
50 };
51
52 const getBadgeContent = (type) => {
53 switch (type) {
54 case "checkmark":
55 return "✓";
56 case "star":
57 return "★";
58 case "heart":
59 return "♥";
60 case "shield":
61 return "🛡️";
62 case "lock":
63 return "🔒";
64 case "verified":
65 return `<svg viewBox="0 0 24 24" width="16" height="16">
66 <path fill="white" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path>
67 </svg>`;
68 default:
69 return "✓";
70 }
71 };
72
73 // Function to get trusted users from local storage
74 const getTrustedUsers = () => {
75 const storedUsers = localStorage.getItem(TRUSTED_USERS_STORAGE_KEY);
76 return storedUsers ? JSON.parse(storedUsers) : [];
77 };
78
79 // Function to save trusted users to local storage
80 const saveTrustedUsers = (users) => {
81 localStorage.setItem(TRUSTED_USERS_STORAGE_KEY, JSON.stringify(users));
82 };
83
84 // Populate default trusted users if not already set
85 if (!localStorage.getItem(TRUSTED_USERS_STORAGE_KEY)) {
86 const defaultTrustedUsers = [
87 "bsky.app",
88 "nytimes.com",
89 "wired.com",
90 "theathletic.bsky.social",
91 ];
92 saveTrustedUsers(defaultTrustedUsers);
93 console.log("Added default trusted users:", defaultTrustedUsers);
94 }
95
96 // Function to add a trusted user
97 const addTrustedUser = (handle) => {
98 const users = getTrustedUsers();
99 if (!users.includes(handle)) {
100 users.push(handle);
101 saveTrustedUsers(users);
102 }
103 };
104
105 // Function to remove a trusted user
106 const removeTrustedUser = (handle) => {
107 const users = getTrustedUsers();
108 const updatedUsers = users.filter((user) => user !== handle);
109 saveTrustedUsers(updatedUsers);
110 };
111
112 // Cache functions
113 const getVerificationCache = () => {
114 const cache = localStorage.getItem(VERIFICATION_CACHE_STORAGE_KEY);
115 return cache ? JSON.parse(cache) : {};
116 };
117
118 const saveVerificationCache = (cache) => {
119 localStorage.setItem(VERIFICATION_CACHE_STORAGE_KEY, JSON.stringify(cache));
120 };
121
122 const getCachedVerifications = (user) => {
123 const cache = getVerificationCache();
124 return cache[user] || null;
125 };
126
127 const cacheVerifications = (user, records) => {
128 const cache = getVerificationCache();
129 cache[user] = {
130 records,
131 timestamp: Date.now(),
132 };
133 saveVerificationCache(cache);
134 };
135
136 const isCacheValid = (cacheEntry) => {
137 return cacheEntry && Date.now() - cacheEntry.timestamp < CACHE_EXPIRY_TIME;
138 };
139
140 // Function to remove a specific user from the verification cache
141 const removeUserFromCache = (handle) => {
142 const cache = getVerificationCache();
143 if (cache[handle]) {
144 delete cache[handle];
145 saveVerificationCache(cache);
146 console.log(`Removed ${handle} from verification cache`);
147 }
148 };
149
150 const clearCache = () => {
151 localStorage.removeItem(VERIFICATION_CACHE_STORAGE_KEY);
152 console.log("Verification cache cleared");
153 };
154
155 // Store all verifiers for a profile
156 let profileVerifiers = [];
157
158 // Store current profile DID
159 let currentProfileDid = null;
160
161 // Function to check if a trusted user has verified the current profile
162 const checkTrustedUserVerifications = async (profileDid) => {
163 currentProfileDid = profileDid; // Store for recheck functionality
164 const trustedUsers = getTrustedUsers();
165 profileVerifiers = []; // Reset the verifiers list
166
167 if (trustedUsers.length === 0) {
168 console.log("No trusted users to check for verifications");
169 return false;
170 }
171
172 console.log(`Checking if any trusted users have verified ${profileDid}`);
173
174 // Use Promise.all to fetch all verification data in parallel
175 const verificationPromises = trustedUsers.map(async (trustedUser) => {
176 try {
177 // Helper function to fetch all verification records with pagination
178 const fetchAllVerifications = async (user) => {
179 // Check cache first
180 const cachedData = getCachedVerifications(user);
181 if (cachedData && isCacheValid(cachedData)) {
182 console.log(`Using cached verification data for ${user}`);
183 return cachedData.records;
184 }
185
186 console.log(`Fetching fresh verification data for ${user}`);
187 let allRecords = [];
188 let cursor = null;
189 let hasMore = true;
190
191 while (hasMore) {
192 const url = cursor
193 ? `https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${user}&collection=app.bsky.graph.verification&cursor=${cursor}`
194 : `https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${user}&collection=app.bsky.graph.verification`;
195
196 const response = await fetch(url);
197 const data = await response.json();
198
199 if (data.records && data.records.length > 0) {
200 allRecords = [...allRecords, ...data.records];
201 }
202
203 if (data.cursor) {
204 cursor = data.cursor;
205 } else {
206 hasMore = false;
207 }
208 }
209
210 // Save to cache
211 cacheVerifications(user, allRecords);
212 return allRecords;
213 };
214
215 // Fetch all verification records for this trusted user
216 const records = await fetchAllVerifications(trustedUser);
217
218 console.log(`Received verification data from ${trustedUser}`, {
219 records,
220 });
221
222 // Check if this trusted user has verified the current profile
223 if (records.length > 0) {
224 for (const record of records) {
225 if (record.value && record.value.subject === profileDid) {
226 console.log(
227 `${profileDid} is verified by trusted user ${trustedUser}`,
228 );
229
230 // Add to verifiers list
231 profileVerifiers.push(trustedUser);
232 break; // Once we find a verification, we can stop checking
233 }
234 }
235 }
236 return { trustedUser, success: true };
237 } catch (error) {
238 console.error(
239 `Error checking verifications from ${trustedUser}:`,
240 error,
241 );
242 return { trustedUser, success: false, error };
243 }
244 });
245
246 // Wait for all verification checks to complete
247 const results = await Promise.all(verificationPromises);
248
249 // Log summary of API calls
250 console.log(`API calls completed: ${results.length}`);
251 console.log(`Successful calls: ${results.filter((r) => r.success).length}`);
252 console.log(`Failed calls: ${results.filter((r) => !r.success).length}`);
253
254 // If we have verifiers, display the badge
255 if (profileVerifiers.length > 0) {
256 displayVerificationBadge(profileVerifiers);
257 return true;
258 }
259
260 console.log(`${profileDid} is not verified by any trusted users`);
261
262 return false;
263 };
264
265 // Function to display verification badge on the profile
266 const displayVerificationBadge = (verifierHandles) => {
267 // Find the profile header or name element to add the badge to
268 const nameElements = document.querySelectorAll(
269 '[data-testid="profileHeaderDisplayName"]',
270 );
271 const nameElement = nameElements[nameElements.length - 1];
272
273 console.log("nameElement", nameElement);
274
275 if (nameElement) {
276 // Remove existing badge if present
277 const existingBadge = document.getElementById(
278 "user-trusted-verification-badge",
279 );
280 if (existingBadge) {
281 existingBadge.remove();
282 }
283
284 const badge = document.createElement("span");
285 badge.id = "user-trusted-verification-badge";
286
287 // Get user badge preferences
288 const badgeType = getBadgeType();
289 const badgeColor = getBadgeColor();
290
291 // Set badge content based on type
292 badge.innerHTML = getBadgeContent(badgeType);
293
294 // check if there is a div with button underneath
295 // Check if this user is verified by Bluesky
296 const isBlueskyVerified = nameElement.querySelector("div button");
297 if (isBlueskyVerified) isBlueskyVerified.remove();
298
299 // Create tooltip text with all verifiers
300 const verifiersText =
301 verifierHandles.length > 1
302 ? `Verified by: ${verifierHandles.join(", ")}`
303 : `Verified by ${verifierHandles[0]}`;
304
305 badge.title = verifiersText;
306 badge.style.cssText = `
307 background-color: ${badgeColor};
308 color: white;
309 border-radius: 50%;
310 width: 22px;
311 height: 22px;
312 margin-left: 8px;
313 font-size: 14px;
314 font-weight: bold;
315 cursor: help;
316 display: inline-flex;
317 align-items: center;
318 justify-content: center;
319 vertical-align: 5px;
320 `;
321
322 // Add a click event to show all verifiers
323 badge.addEventListener("click", (e) => {
324 e.stopPropagation();
325 showVerifiersPopup(verifierHandles);
326 });
327
328 nameElement.appendChild(badge);
329 }
330 };
331
332 // Function to show a popup with all verifiers
333 const showVerifiersPopup = (verifierHandles) => {
334 // Remove existing popup if any
335 const existingPopup = document.getElementById("verifiers-popup");
336 if (existingPopup) {
337 existingPopup.remove();
338 }
339
340 // Create popup
341 const popup = document.createElement("div");
342 popup.id = "verifiers-popup";
343 popup.style.cssText = `
344 position: fixed;
345 top: 50%;
346 left: 50%;
347 transform: translate(-50%, -50%);
348 background-color: #24273A;
349 padding: 20px;
350 border-radius: 10px;
351 z-index: 10002;
352 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
353 max-width: 400px;
354 width: 90%;
355 `;
356
357 // Create popup content
358 popup.innerHTML = `
359 <h3 style="margin-top: 0; color: white;">Profile Verifiers</h3>
360 <div style="max-height: 300px; overflow-y: auto;">
361 ${verifierHandles
362 .map(
363 (handle) => `
364 <div style="padding: 8px 0; border-bottom: 1px solid #444; color: white;">
365 ${handle}
366 </div>
367 `,
368 )
369 .join("")}
370 </div>
371 <button id="close-verifiers-popup" style="
372 margin-top: 15px;
373 padding: 8px 15px;
374 background-color: #473A3A;
375 color: white;
376 border: none;
377 border-radius: 4px;
378 cursor: pointer;
379 ">Close</button>
380 `;
381
382 // Add to body
383 document.body.appendChild(popup);
384
385 // Add close handler
386 document
387 .getElementById("close-verifiers-popup")
388 .addEventListener("click", () => {
389 popup.remove();
390 });
391
392 // Close when clicking outside
393 document.addEventListener("click", function closePopup(e) {
394 if (!popup.contains(e.target)) {
395 popup.remove();
396 document.removeEventListener("click", closePopup);
397 }
398 });
399 };
400
401 // Create settings modal
402 let settingsModal = null;
403
404 // Function to update the list of trusted users in the UI
405 const updateTrustedUsersList = () => {
406 const trustedUsersList = document.getElementById("trustedUsersList");
407 if (!trustedUsersList) return;
408
409 const users = getTrustedUsers();
410 trustedUsersList.innerHTML = "";
411
412 if (users.length === 0) {
413 trustedUsersList.innerHTML = "<p>No trusted users added yet.</p>";
414 return;
415 }
416
417 for (const user of users) {
418 const userItem = document.createElement("div");
419 userItem.style.cssText = `
420 display: flex;
421 justify-content: space-between;
422 align-items: center;
423 padding: 8px 0;
424 border-bottom: 1px solid #eee;
425 `;
426
427 userItem.innerHTML = `
428 <span>${user}</span>
429 <button class="remove-user" data-handle="${user}" style="background-color: #CE3838; color: white; border: none; border-radius: 4px; padding: 5px 10px; cursor: pointer;">Remove</button>
430 `;
431
432 trustedUsersList.appendChild(userItem);
433 }
434
435 // Add event listeners to remove buttons
436 const removeButtons = document.querySelectorAll(".remove-user");
437 for (const btn of removeButtons) {
438 btn.addEventListener("click", (e) => {
439 const handle = e.target.getAttribute("data-handle");
440 removeTrustedUser(handle);
441 removeUserFromCache(handle);
442 updateTrustedUsersList();
443 });
444 }
445 };
446
447 const searchUsers = async (searchQuery) => {
448 if (!searchQuery || searchQuery.length < 2) return [];
449
450 try {
451 const response = await fetch(
452 `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?term=${encodeURIComponent(searchQuery)}&limit=5`,
453 );
454 const data = await response.json();
455 return data.actors || [];
456 } catch (error) {
457 console.error("Error searching for users:", error);
458 return [];
459 }
460 };
461
462 // Function to create and show the autocomplete dropdown
463 const showAutocompleteResults = (results, inputElement) => {
464 // Remove existing dropdown if any
465 const existingDropdown = document.getElementById("autocomplete-dropdown");
466 if (existingDropdown) existingDropdown.remove();
467
468 if (results.length === 0) return;
469
470 // Create dropdown
471 const dropdown = document.createElement("div");
472 dropdown.id = "autocomplete-dropdown";
473 dropdown.style.cssText = `
474 position: absolute;
475 background-color: #2A2E3D;
476 border: 1px solid #444;
477 border-radius: 4px;
478 box-shadow: 0 4px 8px rgba(0,0,0,0.2);
479 max-height: 300px;
480 overflow-y: auto;
481 width: ${inputElement.offsetWidth}px;
482 z-index: 10002;
483 margin-top: 2px;
484 `;
485
486 // Position dropdown below input
487 const inputRect = inputElement.getBoundingClientRect();
488 dropdown.style.left = `${inputRect.left}px`;
489 dropdown.style.top = `${inputRect.bottom}px`;
490
491 // Add results to dropdown
492 for (const user of results) {
493 const userItem = document.createElement("div");
494 userItem.className = "autocomplete-item";
495 userItem.style.cssText = `
496 display: flex;
497 align-items: center;
498 padding: 8px 12px;
499 cursor: pointer;
500 color: white;
501 border-bottom: 1px solid #444;
502 `;
503 userItem.onmouseover = () => {
504 userItem.style.backgroundColor = "#3A3F55";
505 };
506 userItem.onmouseout = () => {
507 userItem.style.backgroundColor = "";
508 };
509
510 // Add profile picture
511 const avatar = document.createElement("img");
512 avatar.src = user.avatar || "https://bsky.app/static/default-avatar.png";
513 avatar.style.cssText = `
514 width: 32px;
515 height: 32px;
516 border-radius: 50%;
517 margin-right: 10px;
518 object-fit: cover;
519 `;
520
521 // Add user info
522 const userInfo = document.createElement("div");
523 userInfo.style.cssText = `
524 display: flex;
525 flex-direction: column;
526 `;
527
528 const displayName = document.createElement("div");
529 displayName.textContent = user.displayName || user.handle;
530 displayName.style.fontWeight = "bold";
531
532 const handle = document.createElement("div");
533 handle.textContent = user.handle;
534 handle.style.fontSize = "0.8em";
535 handle.style.opacity = "0.8";
536
537 userInfo.appendChild(displayName);
538 userInfo.appendChild(handle);
539
540 userItem.appendChild(avatar);
541 userItem.appendChild(userInfo);
542
543 // Handle click on user item
544 userItem.addEventListener("click", () => {
545 inputElement.value = user.handle;
546 dropdown.remove();
547 });
548
549 dropdown.appendChild(userItem);
550 }
551
552 document.body.appendChild(dropdown);
553
554 // Close dropdown when clicking outside
555 document.addEventListener("click", function closeDropdown(e) {
556 if (e.target !== inputElement && !dropdown.contains(e.target)) {
557 dropdown.remove();
558 document.removeEventListener("click", closeDropdown);
559 }
560 });
561 };
562
563 // Function to import verifications from the current user
564 const importVerificationsFromSelf = async () => {
565 try {
566 // Check if we can determine the current user
567 const bskyStorageData = localStorage.getItem("BSKY_STORAGE");
568 let userData = null;
569
570 if (bskyStorageData) {
571 try {
572 const bskyStorage = JSON.parse(bskyStorageData);
573 if (bskyStorage.session.currentAccount) {
574 userData = bskyStorage.session.currentAccount;
575 }
576 } catch (error) {
577 console.error("Error parsing BSKY_STORAGE data:", error);
578 }
579 }
580
581 if (!userData || !userData.handle) {
582 alert(
583 "Could not determine your Bluesky handle. Please ensure you're logged in.",
584 );
585 return;
586 }
587
588 if (!userData || !userData.handle) {
589 alert(
590 "Unable to determine your Bluesky handle. Make sure you're logged in.",
591 );
592 return;
593 }
594
595 const userHandle = userData.handle;
596
597 // Show loading state
598 const importButton = document.getElementById("importVerificationsBtn");
599 const originalText = importButton.textContent;
600 importButton.textContent = "Importing...";
601 importButton.disabled = true;
602
603 // Fetch verification records from the user's account with pagination
604 let allRecords = [];
605 let cursor = null;
606 let hasMore = true;
607
608 while (hasMore) {
609 const url = cursor
610 ? `https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${userHandle}&collection=app.bsky.graph.verification&cursor=${cursor}`
611 : `https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${userHandle}&collection=app.bsky.graph.verification`;
612
613 const verificationResponse = await fetch(url);
614 const data = await verificationResponse.json();
615
616 if (data.records && data.records.length > 0) {
617 allRecords = [...allRecords, ...data.records];
618 }
619
620 if (data.cursor) {
621 cursor = data.cursor;
622 } else {
623 hasMore = false;
624 }
625 }
626
627 const verificationData = { records: allRecords };
628
629 if (!verificationData.records || verificationData.records.length === 0) {
630 alert("No verification records found in your account.");
631 importButton.textContent = originalText;
632 importButton.disabled = false;
633 return;
634 }
635
636 // Extract the handles of verified users
637 const verifiedUsers = [];
638 for (const record of verificationData.records) {
639 console.log(record.value.handle);
640 verifiedUsers.push(record.value.handle);
641 }
642
643 // Add all found users to trusted users
644 let addedCount = 0;
645 for (const handle of verifiedUsers) {
646 const existingUsers = getTrustedUsers();
647 if (!existingUsers.includes(handle)) {
648 addTrustedUser(handle);
649 addedCount++;
650 }
651 }
652
653 // Update the UI
654 updateTrustedUsersList();
655
656 // Reset button state
657 importButton.textContent = originalText;
658 importButton.disabled = false;
659
660 // Show result
661 alert(
662 `Successfully imported ${addedCount} verified users from your account.`,
663 );
664 } catch (error) {
665 console.error("Error importing verifications:", error);
666 alert("Error importing verifications. Check console for details.");
667 const importButton = document.getElementById("importVerificationsBtn");
668 if (importButton) {
669 importButton.textContent = "Import Verifications";
670 importButton.disabled = false;
671 }
672 }
673 };
674
675 const addSettingsButton = () => {
676 // Check if we're on the settings page
677 if (!window.location.href.includes("bsky.app/settings")) {
678 return;
679 }
680
681 // Check if our button already exists to avoid duplicates
682 if (document.getElementById("community-verifications-settings-button")) {
683 return;
684 }
685
686 // Find the right place to insert our button (after content-and-media link)
687 const contentMediaLink = document.querySelector(
688 'a[href="/settings/content-and-media"]',
689 );
690 if (!contentMediaLink) {
691 console.log("Could not find content-and-media link to insert after");
692 return;
693 }
694
695 // Clone the existing link and modify it
696 const verificationButton = contentMediaLink.cloneNode(true);
697 verificationButton.id = "community-verifications-settings-button";
698 verificationButton.href = "#"; // No actual link, we'll handle click with JS
699 verificationButton.setAttribute("aria-label", "Community Verifications");
700
701 const highlightColor =
702 verificationButton.firstChild.style.backgroundColor || "rgb(30,41,54)";
703
704 // Add hover effect to highlight the button
705 verificationButton.addEventListener("mouseover", () => {
706 verificationButton.firstChild.style.backgroundColor = highlightColor;
707 });
708
709 verificationButton.addEventListener("mouseout", () => {
710 verificationButton.firstChild.style.backgroundColor = null;
711 });
712
713 // Update the text content
714 const textDiv = verificationButton.querySelector(".css-146c3p1");
715 if (textDiv) {
716 textDiv.textContent = "Community Verifications";
717 }
718
719 // Update the icon
720 const iconDiv = verificationButton.querySelector(
721 ".css-175oi2r[style*='width: 28px']",
722 );
723 if (iconDiv) {
724 iconDiv.innerHTML = `
725 <svg fill="none" width="28" viewBox="0 0 24 24" height="28" style="color: rgb(241, 243, 245);">
726 <path fill="hsl(211, 20%, 95.3%)" d="M21.2,9.3c-0.5-0.5-1.1-0.7-1.8-0.7h-2.3V6.3c0-2.1-1.7-3.7-3.7-3.7h-3c-2.1,0-3.7,1.7-3.7,3.7v2.3H4.6
727 c-0.7,0-1.3,0.3-1.8,0.7c-0.5,0.5-0.7,1.1-0.7,1.8v9.3c0,0.7,0.3,1.3,0.7,1.8c0.5,0.5,1.1,0.7,1.8,0.7h14.9c0.7,0,1.3-0.3,1.8-0.7
728 c0.5-0.5,0.7-1.1,0.7-1.8v-9.3C22,10.4,21.7,9.8,21.2,9.3z M14.1,15.6l-1.3,1.3c-0.1,0.1-0.3,0.2-0.5,0.2c-0.2,0-0.3-0.1-0.5-0.2l-3.3-3.3
729 c-0.1-0.1-0.2-0.3-0.2-0.5c0-0.2,0.1-0.3,0.2-0.5l1.3-1.3c0.1-0.1,0.3-0.2,0.5-0.2c0.2,0,0.3,0.1,0.5,0.2l1.5,1.5l4.2-4.2
730 c0.1-0.1,0.3-0.2,0.5-0.2c0.2,0,0.3,0.1,0.5,0.2l1.3,1.3c0.1,0.1,0.2,0.3,0.2,0.5c0,0.2-0.1,0.3-0.2,0.5L14.1,15.6z M9.7,6.3
731 c0-0.9,0.7-1.7,1.7-1.7h3c0.9,0,1.7,0.7,1.7,1.7v2.3H9.7V6.3z"/>
732 </svg>
733 `;
734 }
735
736 // Insert our button after the content-and-media link
737 const parentElement = contentMediaLink.parentElement;
738 parentElement.insertBefore(
739 verificationButton,
740 contentMediaLink.nextSibling,
741 );
742
743 // Add click event to open our settings modal
744 verificationButton.addEventListener("click", (e) => {
745 e.preventDefault();
746 if (settingsModal) {
747 settingsModal.style.display = "flex";
748 updateTrustedUsersList();
749 } else {
750 createSettingsModal();
751 }
752 });
753
754 console.log("Added Community Verifications button to settings page");
755 };
756
757 // Function to create the settings modal
758 const createSettingsModal = () => {
759 // Create modal container
760 settingsModal = document.createElement("div");
761 settingsModal.id = "bsky-trusted-settings-modal";
762 settingsModal.style.cssText = `
763 display: flex;
764 position: fixed;
765 top: 0;
766 left: 0;
767 width: 100%;
768 height: 100%;
769 background-color: rgba(0, 0, 0, 0.5);
770 z-index: 10001;
771 justify-content: center;
772 align-items: center;
773 `;
774
775 // Create modal content
776 const modalContent = document.createElement("div");
777 modalContent.style.cssText = `
778 background-color: #24273A;
779 padding: 20px;
780 border-radius: 10px;
781 width: 400px;
782 max-height: 80vh;
783 overflow-y: auto;
784 `;
785
786 // Create modal header
787 const modalHeader = document.createElement("div");
788 modalHeader.innerHTML = `<h2 style="margin-top: 0;">Trusted Bluesky Users</h2>`;
789
790 const badgeCustomization = document.createElement("div");
791 badgeCustomization.style.cssText = `
792 margin-top: 15px;
793 padding-top: 15px;
794 border-top: 1px solid #eee;
795 `;
796
797 badgeCustomization.innerHTML = `
798 <h2 style="margin-top: 0; color: white;">Badge Customization</h3>
799
800 <div style="margin-bottom: 1rem;">
801 <p style="margin-bottom: 8px; color: white;">Badge Type:</p>
802 <div style="display: flex; flex-wrap: wrap; gap: 10px;">
803 <label style="display: flex; align-items: center; cursor: pointer; color: white;">
804 <input type="radio" name="badgeType" value="checkmark" ${getBadgeType() === "checkmark" ? "checked" : ""}>
805 <span style="margin-left: 5px;">Checkmark (✓)</span>
806 </label>
807 <label style="display: flex; align-items: center; cursor: pointer; color: white;">
808 <input type="radio" name="badgeType" value="star" ${getBadgeType() === "star" ? "checked" : ""}>
809 <span style="margin-left: 5px;">Star (★)</span>
810 </label>
811 <label style="display: flex; align-items: center; cursor: pointer; color: white;">
812 <input type="radio" name="badgeType" value="heart" ${getBadgeType() === "heart" ? "checked" : ""}>
813 <span style="margin-left: 5px;">Heart (♥)</span>
814 </label>
815 <label style="display: flex; align-items: center; cursor: pointer; color: white;">
816 <input type="radio" name="badgeType" value="shield" ${getBadgeType() === "shield" ? "checked" : ""}>
817 <span style="margin-left: 5px;">Shield (🛡️)</span>
818 </label>
819 <label style="display: flex; align-items: center; cursor: pointer; color: white;">
820 <input type="radio" name="badgeType" value="lock" ${getBadgeType() === "lock" ? "checked" : ""}>
821 <span style="margin-left: 5px;">Lock (🔒)</span>
822 </label>
823 <label style="display: flex; align-items: center; cursor: pointer; color: white;">
824 <input type="radio" name="badgeType" value="verified" ${getBadgeType() === "verified" ? "checked" : ""}>
825 <span style="margin-left: 5px;">Verified</span>
826 </label>
827 </div>
828 </div>
829
830 <div>
831 <p style="margin-bottom: 8px; color: white;">Badge Color:</p>
832 <div style="display: flex; align-items: center;">
833 <input type="color" id="badgeColorPicker" value="${getBadgeColor()}" style="margin-right: 10px;">
834 <span id="badgeColorPreview" style="display: inline-block; width: 24px; height: 24px; background-color: ${getBadgeColor()}; border-radius: 50%; margin-right: 10px;"></span>
835 <button id="resetBadgeColor" style="padding: 5px 10px; background: #473A3A; color: white; border: none; border-radius: 4px; cursor: pointer;">Reset to Default</button>
836 </div>
837 </div>
838
839 <div style="margin-top: 20px; margin-bottom: 1rem;">
840 <p style="color: white;">Preview:</p>
841 <div style="display: flex; align-items: center; margin-top: 8px;">
842 <span style="color: white; font-weight: bold;">User Name</span>
843 <span id="badgePreview" style="
844 background-color: ${getBadgeColor()};
845 color: white;
846 border-radius: 50%;
847 width: 22px;
848 height: 22px;
849 margin-left: 8px;
850 font-size: 14px;
851 font-weight: bold;
852 display: inline-flex;
853 align-items: center;
854 justify-content: center;
855 ">${getBadgeContent(getBadgeType())}</span>
856 </div>
857 </div>
858 `;
859
860 // Add the badge customization section to the modal content
861 modalContent.appendChild(badgeCustomization);
862
863 // Add event listeners for the badge customization controls
864 setTimeout(() => {
865 // Badge type selection
866 const badgeTypeRadios = document.querySelectorAll(
867 'input[name="badgeType"]',
868 );
869 for (const radio of badgeTypeRadios) {
870 radio.addEventListener("change", (e) => {
871 const selectedType = e.target.value;
872 saveBadgeType(selectedType);
873 updateBadgePreview();
874 });
875 }
876
877 // Badge color picker
878 const colorPicker = document.getElementById("badgeColorPicker");
879 const colorPreview = document.getElementById("badgeColorPreview");
880
881 colorPicker.addEventListener("input", (e) => {
882 const selectedColor = e.target.value;
883 colorPreview.style.backgroundColor = selectedColor;
884 saveBadgeColor(selectedColor);
885 updateBadgePreview();
886 });
887
888 // Reset color button
889 const resetColorBtn = document.getElementById("resetBadgeColor");
890 resetColorBtn.addEventListener("click", () => {
891 colorPicker.value = DEFAULT_BADGE_COLOR;
892 colorPreview.style.backgroundColor = DEFAULT_BADGE_COLOR;
893 saveBadgeColor(DEFAULT_BADGE_COLOR);
894 updateBadgePreview();
895 });
896
897 // Function to update the badge preview
898 function updateBadgePreview() {
899 const badgePreview = document.getElementById("badgePreview");
900 const selectedType = getBadgeType();
901 const selectedColor = getBadgeColor();
902
903 badgePreview.innerHTML = getBadgeContent(selectedType);
904 badgePreview.style.backgroundColor = selectedColor;
905 }
906
907 // Initialize preview
908 updateBadgePreview();
909 }, 100);
910
911 // Create input form
912 const form = document.createElement("div");
913 form.innerHTML = `
914 <p>Add Bluesky handles you trust:</p>
915 <div style="display: flex; margin-bottom: 15px; position: relative;">
916 <input id="trustedUserInput" type="text" placeholder="Search for a user..." style="flex: 1; padding: 8px; margin-right: 10px; border: 1px solid #ccc; border-radius: 4px;">
917 <button id="addTrustedUserBtn" style="background-color: #2D578D; color: white; border: none; border-radius: 4px; padding: 8px 15px; cursor: pointer;">Add</button>
918 </div>
919 `;
920
921 // Create import button
922 const importContainer = document.createElement("div");
923 importContainer.style.cssText = `
924 margin-top: 10px;
925 margin-bottom: 15px;
926 `;
927
928 const importButton = document.createElement("button");
929 importButton.id = "importVerificationsBtn";
930 importButton.textContent = "Import Your Verifications";
931 importButton.style.cssText = `
932 background-color: #2D578D;
933 color: white;
934 border: none;
935 border-radius: 4px;
936 padding: 8px 15px;
937 cursor: pointer;
938 width: 100%;
939 `;
940
941 importButton.addEventListener("click", importVerificationsFromSelf);
942 importContainer.appendChild(importButton);
943
944 // Create trusted users list
945 const trustedUsersList = document.createElement("div");
946 trustedUsersList.id = "trustedUsersList";
947 trustedUsersList.style.cssText = `
948 margin-top: 15px;
949 border-top: 1px solid #eee;
950 padding-top: 15px;
951 `;
952
953 // Create cache control buttons
954 const cacheControls = document.createElement("div");
955 cacheControls.style.cssText = `
956 margin-top: 15px;
957 padding-top: 15px;
958 border-top: 1px solid #eee;
959 `;
960
961 const clearCacheButton = document.createElement("button");
962 clearCacheButton.textContent = "Clear Verification Cache";
963 clearCacheButton.style.cssText = `
964 padding: 8px 15px;
965 background-color: #735A5A;
966 color: white;
967 border: none;
968 border-radius: 4px;
969 cursor: pointer;
970 margin-right: 10px;
971 `;
972 clearCacheButton.addEventListener("click", () => {
973 clearCache();
974 alert(
975 "Verification cache cleared. Fresh data will be fetched on next check.",
976 );
977 });
978
979 cacheControls.appendChild(clearCacheButton);
980
981 // Create close button
982 const closeButton = document.createElement("button");
983 closeButton.textContent = "Close";
984 closeButton.style.cssText = `
985 margin-top: 20px;
986 padding: 8px 15px;
987 background-color: #473A3A;
988 border: none;
989 border-radius: 4px;
990 cursor: pointer;
991 `;
992
993 // Assemble modal
994 modalContent.appendChild(modalHeader);
995 modalContent.appendChild(form);
996 modalContent.appendChild(importContainer);
997 modalContent.appendChild(trustedUsersList);
998 modalContent.appendChild(cacheControls);
999 modalContent.appendChild(closeButton);
1000 settingsModal.appendChild(modalContent);
1001
1002 // Add to document
1003 document.body.appendChild(settingsModal);
1004
1005 const userInput = document.getElementById("trustedUserInput");
1006
1007 // Add input event for autocomplete
1008 let debounceTimeout;
1009 userInput.addEventListener("input", (e) => {
1010 clearTimeout(debounceTimeout);
1011 debounceTimeout = setTimeout(async () => {
1012 const searchQuery = e.target.value.trim();
1013 if (searchQuery.length >= 2) {
1014 const results = await searchUsers(searchQuery);
1015 showAutocompleteResults(results, userInput);
1016 } else {
1017 const dropdown = document.getElementById("autocomplete-dropdown");
1018 if (dropdown) dropdown.remove();
1019 }
1020 }, 300); // Debounce for 300ms
1021 });
1022
1023 // Event listeners
1024 closeButton.addEventListener("click", () => {
1025 settingsModal.style.display = "none";
1026 });
1027
1028 // Function to add a user from the input field
1029 const addUserFromInput = () => {
1030 const input = document.getElementById("trustedUserInput");
1031 const handle = input.value.trim();
1032 if (handle) {
1033 addTrustedUser(handle);
1034 input.value = "";
1035 updateTrustedUsersList();
1036
1037 // Remove dropdown if present
1038 const dropdown = document.getElementById("autocomplete-dropdown");
1039 if (dropdown) dropdown.remove();
1040 }
1041 };
1042
1043 // Add trusted user button event
1044 document
1045 .getElementById("addTrustedUserBtn")
1046 .addEventListener("click", addUserFromInput);
1047
1048 // Add keydown event to input for Enter key
1049 userInput.addEventListener("keydown", (e) => {
1050 if (e.key === "Enter") {
1051 e.preventDefault();
1052 addUserFromInput();
1053 }
1054 });
1055
1056 // Close modal when clicking outside
1057 settingsModal.addEventListener("click", (e) => {
1058 if (e.target === settingsModal) {
1059 settingsModal.style.display = "none";
1060 }
1061 });
1062
1063 // Initialize the list
1064 updateTrustedUsersList();
1065 };
1066
1067 // Function to check the current profile
1068 const checkCurrentProfile = () => {
1069 const currentUrl = window.location.href;
1070 // Only trigger on profile pages
1071 if (
1072 currentUrl.match(/bsky\.app\/profile\/[^\/]+$/) ||
1073 currentUrl.match(/bsky\.app\/profile\/[^\/]+\/follows/) ||
1074 currentUrl.match(/bsky\.app\/profile\/[^\/]+\/followers/)
1075 ) {
1076 const handle = currentUrl.split("/profile/")[1].split("/")[0];
1077 console.log("Detected profile page for:", handle);
1078
1079 // Fetch user profile data
1080 fetch(
1081 `https://public.api.bsky.app/xrpc/com.atproto.repo.getRecord?repo=${handle}&collection=app.bsky.actor.profile&rkey=self`,
1082 )
1083 .then((response) => response.json())
1084 .then((data) => {
1085 console.log("User profile data:", data);
1086
1087 // Extract the DID from the profile data
1088 const did = data.uri.split("/")[2];
1089 console.log("User DID:", did);
1090
1091 // Check if any trusted users have verified this profile using the DID
1092 checkTrustedUserVerifications(did);
1093 })
1094 .catch((error) => {
1095 console.error("Error checking profile:", error);
1096 });
1097
1098 console.log("Bluesky profile detected");
1099 } else {
1100 // Not on a profile page, reset state
1101 currentProfileDid = null;
1102 profileVerifiers = [];
1103
1104 // Remove UI elements if present
1105 const existingBadge = document.getElementById(
1106 "user-trusted-verification-badge",
1107 );
1108 if (existingBadge) {
1109 existingBadge.remove();
1110 }
1111 }
1112 };
1113
1114 const checkUserLinksOnPage = async () => {
1115 // Look for profile links with handles
1116 // Find all profile links and filter to get only one link per parent
1117 const allProfileLinks = Array.from(
1118 document.querySelectorAll('a[href^="/profile/"]:not(:has(*))'),
1119 );
1120
1121 // Use a Map to keep track of parent elements and their first child link
1122 const parentMap = new Map();
1123
1124 // For each link, store only the first one found for each parent
1125 for (const link of allProfileLinks) {
1126 const parent = link.parentElement;
1127 if (parent && !parentMap.has(parent)) {
1128 parentMap.set(parent, link);
1129 }
1130 }
1131
1132 // Get only the first link for each parent
1133 const profileLinks = Array.from(parentMap.values());
1134
1135 if (profileLinks.length === 0) return;
1136
1137 console.log(`Found ${profileLinks.length} possible user links on page`);
1138
1139 // Process profile links to identify user containers
1140 for (const link of profileLinks) {
1141 try {
1142 // Check if we already processed this link
1143 if (link.getAttribute("data-verification-checked") === "true") continue;
1144
1145 // Mark as checked
1146 link.setAttribute("data-verification-checked", "true");
1147
1148 // Extract handle from href
1149 const handle = link.getAttribute("href").split("/profile/")[1];
1150 if (!handle) continue;
1151
1152 // check if there is anything after the handle
1153 const handleTrailing = handle.split("/").length > 1;
1154 if (handleTrailing) continue;
1155
1156 // Find parent container that might contain the handle and verification icon
1157 // Look for containers where this link is followed by another link with the same handle
1158 const parent = link.parentElement;
1159
1160 // If we found a container with the verification icon
1161 if (parent) {
1162 // Check if this user already has our verification badge
1163 if (parent.querySelector(".trusted-user-inline-badge")) continue;
1164
1165 try {
1166 // Fetch user profile data to get DID
1167 const response = await fetch(
1168 `https://public.api.bsky.app/xrpc/com.atproto.repo.getRecord?repo=${handle}&collection=app.bsky.actor.profile&rkey=self`,
1169 );
1170 const data = await response.json();
1171
1172 // Extract the DID from the profile data
1173 const did = data.uri.split("/")[2];
1174
1175 // Check if this user is verified by our trusted users
1176 const trustedUsers = getTrustedUsers();
1177 let isVerified = false;
1178 const verifiers = [];
1179
1180 // Check cache first for each trusted user
1181 for (const trustedUser of trustedUsers) {
1182 const cachedData = getCachedVerifications(trustedUser);
1183
1184 if (cachedData && isCacheValid(cachedData)) {
1185 // Use cached verification data
1186 const records = cachedData.records;
1187
1188 for (const record of records) {
1189 if (record.value && record.value.subject === did) {
1190 isVerified = true;
1191 verifiers.push(trustedUser);
1192 break;
1193 }
1194 }
1195 }
1196 }
1197
1198 // If verified, add a small badge
1199 if (isVerified && verifiers.length > 0) {
1200 // Create a badge element
1201 const smallBadge = document.createElement("span");
1202 smallBadge.className = "trusted-user-inline-badge";
1203
1204 // Get user badge preferences
1205 const badgeType = getBadgeType();
1206 const badgeColor = getBadgeColor();
1207
1208 smallBadge.innerHTML = getBadgeContent(badgeType);
1209
1210 // Create tooltip text with all verifiers
1211 const verifiersText =
1212 verifiers.length > 1
1213 ? `Verified by: ${verifiers.join(", ")}`
1214 : `Verified by ${verifiers[0]}`;
1215
1216 smallBadge.title = verifiersText;
1217 smallBadge.style.cssText = `
1218 background-color: ${badgeColor};
1219 color: white;
1220 border-radius: 50%;
1221 width: 16px;
1222 height: 16px;
1223 font-size: 11px;
1224 font-weight: bold;
1225 cursor: help;
1226 display: inline-flex;
1227 align-items: center;
1228 justify-content: center;
1229 margin-left: 4px;
1230 `;
1231
1232 // Add click event to show verifiers
1233 smallBadge.addEventListener("click", (e) => {
1234 e.stopPropagation();
1235 showVerifiersPopup(verifiers);
1236 });
1237
1238 // Insert badge after the SVG element
1239 parent.firstChild.after(smallBadge);
1240 parent.style.flexDirection = "row";
1241 parent.style.alignItems = "center";
1242
1243 // Look for verification SVG icon in the parent and remove it if it exists
1244 const badgeSvgIcon = Array.from(parent.childNodes).find(
1245 (node) =>
1246 node.nodeType === Node.ELEMENT_NODE &&
1247 node.tagName === "DIV" &&
1248 node.querySelector("svg"),
1249 );
1250 if (badgeSvgIcon) {
1251 badgeSvgIcon.remove();
1252 }
1253 }
1254 } catch (error) {
1255 console.error(`Error checking verification for ${handle}:`, error);
1256 }
1257 }
1258 } catch (error) {
1259 console.error("Error processing profile link:", error);
1260 }
1261 }
1262 };
1263
1264 const observeContentChanges = () => {
1265 // Use a debounced function to check for new user links
1266 const debouncedCheck = () => {
1267 clearTimeout(window.userLinksCheckTimeout);
1268 window.userLinksCheckTimeout = setTimeout(() => {
1269 checkUserLinksOnPage();
1270 }, 300);
1271 };
1272
1273 // Create a mutation observer that watches for DOM changes
1274 const observer = new MutationObserver((mutations) => {
1275 let hasRelevantChanges = false;
1276
1277 // Check if any mutations involve adding new nodes
1278 for (const mutation of mutations) {
1279 if (mutation.addedNodes.length > 0) {
1280 for (const node of mutation.addedNodes) {
1281 if (node.nodeType === Node.ELEMENT_NODE) {
1282 // Check if this element or its children might contain profile links
1283 if (
1284 node.querySelector('a[href^="/profile/"]') ||
1285 (node.tagName === "A" &&
1286 node.getAttribute("href")?.startsWith("/profile/"))
1287 ) {
1288 hasRelevantChanges = true;
1289 break;
1290 }
1291 }
1292 }
1293 }
1294 if (hasRelevantChanges) break;
1295 }
1296
1297 if (hasRelevantChanges) {
1298 debouncedCheck();
1299 }
1300 });
1301
1302 // Observe the entire document for content changes that might include profile links
1303 observer.observe(document.body, { childList: true, subtree: true });
1304
1305 // Also check periodically for posts that might have been loaded but not caught by the observer
1306 setInterval(debouncedCheck, 5000);
1307 };
1308
1309 // Wait for DOM to be fully loaded before initializing
1310 document.addEventListener("DOMContentLoaded", () => {
1311 // Initial check for user links
1312 checkUserLinksOnPage();
1313
1314 // Initial check
1315 setTimeout(checkCurrentProfile, 2000);
1316
1317 // Add settings button if we're on the settings page
1318 if (window.location.href.includes("bsky.app/settings")) {
1319 // Wait for the content-and-media link to appear before adding our button
1320 const waitForSettingsLink = setInterval(() => {
1321 const contentMediaLink = document.querySelector(
1322 'a[href="/settings/content-and-media"]',
1323 );
1324 if (contentMediaLink) {
1325 clearInterval(waitForSettingsLink);
1326 addSettingsButton();
1327 }
1328 }, 200);
1329 }
1330 });
1331
1332 // Start observing for content changes to detect newly loaded posts
1333 observeContentChanges();
1334
1335 // Set up a MutationObserver to watch for URL changes
1336 const observeUrlChanges = () => {
1337 let lastUrl = location.href;
1338
1339 const observer = new MutationObserver(() => {
1340 if (location.href !== lastUrl) {
1341 const oldUrl = lastUrl;
1342 lastUrl = location.href;
1343 console.log("URL changed from:", oldUrl, "to:", location.href);
1344
1345 // Reset current profile DID
1346 currentProfileDid = null;
1347 profileVerifiers = [];
1348
1349 // Clean up UI elements
1350 const existingBadge = document.getElementById(
1351 "user-trusted-verification-badge",
1352 );
1353 if (existingBadge) {
1354 existingBadge.remove();
1355 }
1356
1357 // Check if we're on a profile page now
1358 setTimeout(checkCurrentProfile, 500); // Small delay to ensure DOM has updated
1359
1360 if (window.location.href.includes("bsky.app/settings")) {
1361 // Give the page a moment to fully load
1362 setTimeout(addSettingsButton, 200);
1363 }
1364 }
1365 });
1366
1367 observer.observe(document, { subtree: true, childList: true });
1368 };
1369
1370 // Start observing for URL changes
1371 observeUrlChanges();
1372})();