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