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