您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds quick buttons for weblink, mute, and block, directly on posts, always visible, not even hidden in the post dropdown menu. Also adds a link to clearsky from the three-dot menu on profiles. Tested and works on web as of dec 14, msg me on bsky (lauren1701.bsky.social) if it breaks
// ==UserScript== // @name Quickblock And Such (prev. BEPC) // @version 0.0.24 // @description Adds quick buttons for weblink, mute, and block, directly on posts, always visible, not even hidden in the post dropdown menu. Also adds a link to clearsky from the three-dot menu on profiles. Tested and works on web as of dec 14, msg me on bsky (lauren1701.bsky.social) if it breaks // @match https://bsky.app/* // @namespace https://lauren1701.bsky.social // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; console.log("Quickblock: top of script"); // css for clearsky link hover const style = document.createElement('style'); style.textContent = ` .menu-item-hover:hover { background-color: rgba(128,128,128,0.1) !important; } `; document.head.appendChild(style); let profileCache = {}; // Get auth token from localStorage function account() { const storedData = localStorage.getItem('BSKY_STORAGE'); try { const localStorageData = JSON.parse(storedData); return {account: localStorageData.session.currentAccount, token: localStorageData.session.currentAccount.accessJwt, hostApi: localStorageData.session.currentAccount.pdsUrl.replace(/\/*$/, '')}; } catch (error) { console.error('Failed to parse session data:', error); throw error; } } function showToast(message, duration = 3000) { const toast = document.createElement('div'); toast.textContent = message; toast.style.cssText = ` position: fixed; bottom: 20px; left: 20px; background: rgba(0, 0, 0, 0.8); color: white; padding: 12px 24px; border-radius: 8px; z-index: 10000; transition: opacity ${duration/1000}s; `; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), duration); }, duration); } function hideUserPosts(username) { // Don't hide posts if we're on a profile page if (window.location.pathname.match(/\/profile\/[^\/]+\/?([?#].*)?$/)) { return; } const selectors = [ `[data-testid="feedItem-by-${username}"]`, `[data-testid="postThreadItem-by-${username}"]` ]; selectors.forEach(selector => { const posts = document.querySelectorAll(selector); posts.forEach(post => { // Animate the post out post.style.display = 'inherit'; const height = post.offsetHeight; post.style.height = height + 'px'; post.style.transition = 'opacity 0.3s, height 0.3s'; // After animation, collapse the height setTimeout(() => { post.style.height = '0'; post.style.margin = '0'; post.style.padding = '0'; post.style.opacity = '0'; post.style.overflow = 'hidden'; setTimeout(() => { if (post.style.display === "initial") return; post.style = 'display: none;'; }, 400); }, 5); }); }); } function unhideUserPosts(username) { // Don't hide posts if we're on a profile page if (window.location.pathname.match(/\/profile\/[^\/]+\/?([?#].*)?$/)) { return; } const selectors = [ `[data-testid="feedItem-by-${username}"]`, `[data-testid="postThreadItem-by-${username}"]` ]; selectors.forEach(selector => { const posts = document.querySelectorAll(selector); posts.forEach(post => { post.style = 'display: initial;'; // Animate the post out }); }); } // Create button container and style it function createButtonContainer() { const container = document.createElement('div'); container.className = 'enhanced-post-controls'; return container; } // Create a button with common styling function createButton(emoji, label, color = 'inherit') { const button = document.createElement('button'); button.innerHTML = emoji; button.title = label; const opacity = '0.4'; button.style.cssText = ` background: none; border: none; cursor: pointer; font-size: 14px; padding: 2px; margin-left: 4px; color: ${color}; opacity: ${opacity}; transition: opacity 0.2s, transform 0.2s; `; button.addEventListener('mouseenter', () => { button.style.opacity = '1'; button.style.transform = 'scale(1.1)'; }); button.addEventListener('mouseleave', () => { button.style.opacity = opacity; button.style.transform = 'scale(1)'; }); return button; } // Extract handle from post element function extractHandle(postElement) { // Look for the handle link element const handleElement = postElement.querySelector('a[href^="/profile/"]'); if (handleElement) { const handle = handleElement.getAttribute('href').split('/profile/')[1]; return handle.replace(/\/post.*/, ""); } return null; } const mute_svg = `<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-volume-x"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`; const external_link_svg = `<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`; const block_svg = `<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-slash"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>`; function addClearskyLink(menu, user) { if (!menu || menu.querySelector('.clearsky-link')) return; const last = menu.children[menu.children.length-1]; const cloned = last.cloneNode(true); const link = document.createElement('a'); link.href = `https://clearsky.app/${user}/lists`; // Set your target URL link.target = '_blank'; link.rel = 'noopener noreferrer'; link.className = cloned.className + ' menu-item-hover clearsky-link'; // Add our new class link.setAttribute('style', cloned.getAttribute('style')); link.setAttribute('role', 'menuitem'); link.setAttribute('tabindex', '-1'); link.setAttribute('aria-label', 'View on Clearsky'); link.setAttribute('data-testid', 'profileHeaderDropdownDataBtn'); //console.log(cloned.children); link.appendChild(cloned.children[0]); link.appendChild(cloned.children[0]); link.children[0].innerText = "View on Clearsky"; link.children[1].innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="rgba(128,128,0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-moon"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`; menu.appendChild(link); } // Add controls to a post function addControlsToPost(post) { if (!post || post.querySelector('.enhanced-post-controls')) return; const handle = extractHandle(post); if (!handle) { console.log("Quickblock: No handle found for post", post); return; } console.log("Quickblock: Adding controls for handle:", handle); const container = createButtonContainer(); // Create buttons as before const linkButton = createButton(external_link_svg, "Open profile's website"); linkButton.addEventListener('click', (e) => { e.stopPropagation(); window.open(`https://${handle}`, '_blank'); }); const spacer = document.createElement("div"); spacer.style = "flex-grow: 1;"; const muteButton = createButton(mute_svg, 'Mute User', 'rgb(200, 128, 68)'); muteButton.addEventListener('click', (e) => { e.stopPropagation(); handleMute(handle); }); const blockButton = createButton(block_svg, 'Block User', 'rgb(255, 68, 68)'); blockButton.addEventListener('click', (e) => { e.stopPropagation(); handleBlock(handle); }); container.appendChild(linkButton); container.appendChild(spacer); container.appendChild(muteButton); container.appendChild(blockButton); // Adjust container styling container.style.cssText = ` display: flex; gap: 2px; margin-left: 2px; position: relative; top: 1px; flex: 1; `; // Determine post type and insertion point let insertionPoint; // Check if this is a thread root by looking for "who can reply" const isThreadRoot = !!post.querySelector('button[aria-label="Who can reply"]'); if (isThreadRoot) { // Find a parent div that contains exactly two role="link" divs const allDivs = post.querySelectorAll('div'); for (const div of allDivs) { const linkDivs = div.querySelectorAll(':scope > div[role="link"]'); if (linkDivs.length === 2) { // Insert after this div's parent insertionPoint = div.parentElement; break; } } } else { // Regular feed post or reply - use the date element parent const dateLink = post.querySelector('a[href^="/profile/"][href*="/post/"]'); insertionPoint = dateLink?.parentElement; } if (insertionPoint) { // Insert after the target element if (isThreadRoot) { insertionPoint.insertBefore(container, insertionPoint.children[2]); } else { insertionPoint.appendChild(container); } } else { console.error("Quickblock: No suitable insertion point found in post:", post); } } async function getProfile(actor) { if (profileCache[actor]) { return profileCache[actor]; } const bskyStorage = JSON.parse(localStorage.getItem('BSKY_STORAGE')); const url = `${bskyStorage.session?.currentAccount?.pdsUrl}xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`; const response = await fetch(url, { headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${account().token}`, }, method: 'GET' }); if (!response.ok) throw new Error(`Failed to fetch profile: ${response.statusText}`); const profile = await response.json(); profileCache[actor] = profile; return profile; } // Handle muting async function handleMute(userId) { try { hideUserPosts(userId); // Get the user's DID first const userProfile = await getProfile(userId); // Then make the actual mute request const response = await fetch(`${account().hostApi}/xrpc/app.bsky.graph.muteActor`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${account().token}` }, body: JSON.stringify({ actor: userProfile.did }) }); if (response.ok) { showToast(`Muted ${userId}`); } else { alert('Failed to mute user'); unhideUserPosts(userId); } } catch (error) { console.error('Error muting user:', error); } } // Handle blocking async function handleBlock(userId) { try { hideUserPosts(userId); const userProfile = await getProfile(userId); const bskyStorage = JSON.parse(localStorage.getItem('BSKY_STORAGE')); const url = `${account().hostApi}/xrpc/com.atproto.repo.createRecord`; const body = JSON.stringify({ collection: 'app.bsky.graph.block', repo: bskyStorage.session.currentAccount.did, record: { subject: userProfile.did, createdAt: new Date().toISOString(), $type: 'app.bsky.graph.block', } }); const response = await fetch(url, { headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${account().token}`, }, body, method: 'POST', }); if (!response.ok) throw new Error(`Failed to block user: ${response.statusText}`); showToast(`Blocked ${userId}`); } catch (error) { unhideUserPosts(userId); console.error('Block user error:', error); alert(`Failed to block user "${userId}". Please check the console for more details.`); } } // Initialize function init() { console.log("Quickblock: Initializing"); const observer = new MutationObserver((mutations) => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { // Look for posts using the role="link" attribute and data-testid pattern const posts = node.querySelectorAll('[data-testid^="feedItem-by-"], [data-testid^="postThreadItem-by-"]'); console.log("Quickblock: Found", posts.length, "new posts"); posts.forEach(post => { try { addControlsToPost(post) } catch (e) { showToast(`Quickblock: error adding controls to post, see console: ${e}`); console.error(e); throw e; } }); const m = window.location.pathname.match(/\/profile\/([^\/]+)\/?([?#].*)?$/); if (m) { const menu = document.querySelector("[data-testid^='profileHeaderDropdownListAddRemoveBtn']")?.parentElement; if (menu) { addClearskyLink(menu, m[1]) } } } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); // Handle initial posts const initialPosts = document.querySelectorAll('[data-testid^="feedItem-by-"], [data-testid^="postThreadItem-by-"]'); console.log("Quickblock: Found", initialPosts.length, "initial posts"); initialPosts.forEach(post => addControlsToPost(post)); } // Start the script if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();