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