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