-
-
Save b0o/22521cf73f6c7a5aacbb8cd0f29197ac to your computer and use it in GitHub Desktop.
Bluesky Starter Pack List Adder
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Bluesky Starter Pack List Adder | |
// @namespace http://tampermonkey.net/ | |
// @version 1.0.1 | |
// @description Add users from a Bluesky starter pack to a specified list | |
// @author https://maddison.io/ | |
// @match https://bsky.app/* | |
// @downloadURL https://gist.github.com/b0o/22521cf73f6c7a5aacbb8cd0f29197ac/raw/bsky-starter-pack-list-adder.user.js | |
// @updateURL https://gist.github.com/b0o/22521cf73f6c7a5aacbb8cd0f29197ac/raw/bsky-starter-pack-list-adder.user.js | |
// @icon https://web-cdn.bsky.app/static/apple-touch-icon.png | |
// @grant GM.xmlHttpRequest | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
let currentUrl = location.href; | |
let checkingUrl = false; | |
let isInitialized = false; | |
let elements = null; | |
const style = document.createElement('style'); | |
style.textContent = ` | |
@keyframes bspl-spin { | |
0% { transform: rotate(0deg); } | |
100% { transform: rotate(360deg); } | |
} | |
.bspl-loader { | |
border: 3px solid #f3f3f3; | |
border-radius: 50%; | |
border-top: 3px solid #0066ff; | |
width: 20px; | |
height: 20px; | |
animation: bspl-spin 1s linear infinite; | |
display: inline-block; | |
vertical-align: middle; | |
margin-right: 10px; | |
} | |
.bspl-progress-container { | |
position: fixed; | |
bottom: 80px; | |
right: 20px; | |
padding: 15px; | |
border-radius: 8px; | |
display: none; | |
z-index: 9999; | |
background: #ffffff; | |
color: #000000; | |
border: 1px solid #e6e6e6; | |
box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
} | |
.bspl-list-selector { | |
position: fixed; | |
bottom: 80px; | |
right: 20px; | |
background: #ffffff; | |
border: 1px solid #e6e6e6; | |
border-radius: 8px; | |
padding: 15px; | |
box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
display: none; | |
z-index: 9999; | |
} | |
.bspl-list-selector select { | |
width: 100%; | |
padding: 8px; | |
margin-bottom: 10px; | |
border-radius: 6px; | |
border: 1px solid #e6e6e6; | |
} | |
.bspl-button-container { | |
display: flex; | |
gap: 8px; | |
} | |
.bspl-button-container button { | |
flex: 1; | |
} | |
.bspl-cancel-button { | |
background: none !important; | |
color: rgb(66, 87, 108) !important; | |
} | |
.bspl-list-selector button { | |
width: 100%; | |
} | |
.bspl-toast-container { | |
position: fixed; | |
bottom: 20px; | |
left: 50%; | |
transform: translateX(-50%); | |
z-index: 10000; | |
display: flex; | |
flex-direction: column; | |
gap: 8px; | |
pointer-events: none; | |
} | |
.bspl-toast { | |
background: #dc2626; | |
color: white; | |
padding: 12px 20px; | |
border-radius: 8px; | |
font-size: 14px; | |
opacity: 0; | |
transition: opacity 0.3s ease; | |
pointer-events: none; | |
max-width: 400px; | |
text-align: center; | |
} | |
.bspl-toast.show { | |
opacity: 1; | |
} | |
html.theme--dim .bspl-progress-container, | |
html.theme--dim .bspl-list-selector { | |
background: rgb(30, 41, 54); | |
color: rgb(215, 221, 228); | |
border-color: rgb(46, 64, 82); | |
} | |
html.theme--dim .bspl-loader { | |
border-color: #333333; | |
border-top-color: #0066ff; | |
} | |
html.theme--dim .bspl-list-selector select { | |
background: rgb(30, 41, 54); | |
color: #ffffff; | |
border-color: rgb(46, 64, 82); | |
} | |
html.theme--dim .bspl-cancel-button { | |
color: rgb(174, 187, 201)!important; | |
} | |
.bspl-action-button { | |
background: rgb(32, 139, 254); | |
color: white; | |
border: none; | |
border-radius: 8px; | |
padding: 10px 20px; | |
cursor: pointer; | |
font-weight: 500; | |
} | |
.bspl-action-button:hover { | |
background: rgb(76, 162, 254); | |
} | |
.bspl-action-button:disabled { | |
opacity: 0.5; | |
cursor: not-allowed; | |
} | |
.bspl-hidden { | |
display: none !important; | |
} | |
`; | |
function createElements() { | |
if (elements) return elements; | |
const button = document.createElement('button'); | |
button.textContent = 'Add all to List'; | |
button.className = 'bspl-action-button'; | |
button.style.position = 'fixed'; | |
button.style.bottom = '20px'; | |
button.style.right = '20px'; | |
button.style.zIndex = '9999'; | |
const listSelector = document.createElement('div'); | |
listSelector.className = 'bspl-list-selector'; | |
const listSelect = document.createElement('select'); | |
const buttonContainer = document.createElement('div'); | |
buttonContainer.className = 'bspl-button-container'; | |
const confirmButton = document.createElement('button'); | |
confirmButton.textContent = 'Confirm'; | |
confirmButton.className = 'bspl-action-button'; | |
const cancelButton = document.createElement('button'); | |
cancelButton.textContent = 'Cancel'; | |
cancelButton.className = 'bspl-action-button bspl-cancel-button'; | |
buttonContainer.appendChild(cancelButton); | |
buttonContainer.appendChild(confirmButton); | |
listSelector.appendChild(listSelect); | |
listSelector.appendChild(buttonContainer); | |
const progressContainer = document.createElement('div'); | |
progressContainer.className = 'bspl-progress-container'; | |
const spinner = document.createElement('div'); | |
spinner.className = 'bspl-loader'; | |
const progressText = document.createElement('span'); | |
progressContainer.appendChild(spinner); | |
progressContainer.appendChild(progressText); | |
const toastContainer = document.createElement('div'); | |
toastContainer.className = 'bspl-toast-container'; | |
elements = { | |
button, | |
listSelector, | |
progressContainer, | |
toastContainer, | |
listSelect, | |
spinner, | |
progressText, | |
confirmButton, | |
cancelButton | |
}; | |
// Add event listeners | |
button.addEventListener('click', showListSelector); | |
cancelButton.addEventListener('click', hideListSelector); | |
confirmButton.addEventListener('click', () => { | |
if (listSelect.value) { | |
processStarterPack(listSelect.value); | |
} else { | |
showErrorToast('Please select a list'); | |
} | |
}); | |
return elements; | |
} | |
function isStarterPackPage() { | |
return location.pathname.startsWith('/starter-pack/'); | |
} | |
function cleanupElements() { | |
if (!elements) return; | |
// Hide all UI elements | |
elements.button.style.display = 'none'; | |
elements.listSelector.style.display = 'none'; | |
elements.progressContainer.style.display = 'none'; | |
// Reset states | |
elements.listSelect.value = ''; | |
elements.button.disabled = false; | |
} | |
function initializeElements() { | |
if (!isInitialized) { | |
const elems = createElements(); | |
document.body.appendChild(elems.button); | |
document.body.appendChild(elems.listSelector); | |
document.body.appendChild(elems.progressContainer); | |
document.body.appendChild(elems.toastContainer); | |
isInitialized = true; | |
} | |
} | |
function updateVisibility() { | |
if (!elements) return; | |
if (isStarterPackPage()) { | |
elements.button.style.display = 'block'; | |
} else { | |
cleanupElements(); | |
} | |
} | |
function checkUrl() { | |
if (checkingUrl) return; | |
checkingUrl = true; | |
if (currentUrl !== location.href) { | |
currentUrl = location.href; | |
updateVisibility(); | |
} | |
checkingUrl = false; | |
} | |
function initialize() { | |
document.head.appendChild(style); | |
initializeElements(); | |
updateVisibility(); | |
setInterval(checkUrl, 100); | |
window.addEventListener('popstate', checkUrl); | |
window.addEventListener('hashchange', checkUrl); | |
} | |
function hideListSelector() { | |
if (!elements) return; | |
elements.listSelector.style.display = 'none'; | |
elements.button.disabled = false; | |
elements.listSelect.value = ''; | |
} | |
function showErrorToast(message, duration = 3000) { | |
if (!elements) return; | |
const toast = document.createElement('div'); | |
toast.className = 'bspl-toast'; | |
toast.textContent = message; | |
elements.toastContainer.appendChild(toast); | |
toast.offsetHeight; | |
toast.classList.add('show'); | |
setTimeout(() => { | |
toast.classList.remove('show'); | |
setTimeout(() => { | |
elements.toastContainer.removeChild(toast); | |
}, 300); | |
}, duration); | |
} | |
function getUserData() { | |
const storageData = localStorage.getItem('BSKY_STORAGE'); | |
if (!storageData) { | |
throw new Error('BSKY_STORAGE not found in localStorage'); | |
} | |
try { | |
const data = JSON.parse(storageData); | |
const token = data?.session?.currentAccount?.accessJwt; | |
const did = data?.session?.currentAccount?.did; | |
const pdsUrl = data?.session?.currentAccount?.pdsUrl; | |
if (!token || !did || !pdsUrl) { | |
throw new Error('Missing required user data in BSKY_STORAGE'); | |
} | |
return { token, did, pdsUrl }; | |
} catch (error) { | |
throw new Error('Failed to parse BSKY_STORAGE: ' + error.message); | |
} | |
} | |
function gmFetch(url, options) { | |
return new Promise((resolve, reject) => { | |
const { token } = getUserData(); | |
const headers = { | |
'Authorization': `Bearer ${token}`, | |
...options.headers | |
}; | |
GM.xmlHttpRequest({ | |
url: url, | |
method: options.method || 'GET', | |
headers: headers, | |
data: options.body, | |
credentials: "include", | |
referrer: "https://bsky.app/", | |
onload: function(response) { | |
if (response.status >= 200 && response.status < 300) { | |
resolve({ | |
ok: true, | |
status: response.status, | |
json: () => Promise.resolve(JSON.parse(response.responseText)) | |
}); | |
} else { | |
reject(new Error(`HTTP ${response.status}: ${response.responseText}`)); | |
} | |
}, | |
onerror: function(error) { | |
reject(error); | |
} | |
}); | |
}); | |
} | |
async function fetchUserLists() { | |
try { | |
const { did, pdsUrl } = getUserData(); | |
const response = await gmFetch(`${pdsUrl}xrpc/app.bsky.graph.getLists?actor=${encodeURIComponent(did)}&limit=30`, { | |
method: 'GET', | |
headers: { | |
'Accept': 'application/json' | |
} | |
}); | |
const data = await response.json(); | |
return data.lists; | |
} catch (error) { | |
showErrorToast('Failed to fetch lists: ' + error.message); | |
throw error; | |
} | |
} | |
async function showListSelector() { | |
try { | |
elements.button.disabled = true; | |
const lists = await fetchUserLists(); | |
elements.listSelect.innerHTML = ''; | |
elements.listSelect.appendChild(new Option('Select a list...', '')); | |
lists.forEach(list => { | |
const option = new Option(list.name, list.uri); | |
elements.listSelect.appendChild(option); | |
}); | |
elements.listSelector.style.display = 'block'; | |
} catch (error) { | |
elements.button.disabled = false; | |
showErrorToast('Failed to load lists: ' + error.message); | |
} | |
} | |
async function processStarterPack(selectedListUri) { | |
try { | |
const { pdsUrl } = getUserData(); | |
elements.listSelector.style.display = 'none'; | |
elements.progressContainer.style.display = 'block'; | |
elements.spinner.classList.remove('bspl-hidden'); | |
elements.progressText.textContent = 'Fetching starter pack data...'; | |
const starterPackId = window.location.pathname.split('/').pop(); | |
if (!starterPackId) { | |
throw new Error('Could not find starter pack ID in URL'); | |
} | |
const imgElement = document.querySelector("img[src*='/did:plc:']"); | |
if (!imgElement) { | |
throw new Error('Could not find creator profile image'); | |
} | |
const didMatch = imgElement.src.match("/(did:.*)/"); | |
if (!didMatch || !didMatch[1]) { | |
throw new Error('Could not extract creator DID from image'); | |
} | |
const creatorDid = didMatch[1]; | |
const starterPackUrl = `${pdsUrl}xrpc/app.bsky.graph.getStarterPack?starterPack=at%3A%2F%2F${encodeURIComponent(creatorDid)}%2Fapp.bsky.graph.starterpack%2F${starterPackId}`; | |
const starterPackResponse = await gmFetch(starterPackUrl, { | |
method: 'GET', | |
headers: { | |
'Accept': 'application/json' | |
} | |
}); | |
const starterPackData = await starterPackResponse.json(); | |
if (!starterPackData.starterPack?.list?.uri) { | |
throw new Error('Could not find list URI in starter pack'); | |
} | |
let allUsers = []; | |
let cursor = undefined; | |
do { | |
const params = new URLSearchParams({ | |
list: starterPackData.starterPack.list.uri, | |
limit: '50', | |
...(cursor ? { cursor } : {}) | |
}); | |
elements.progressText.textContent = 'Fetching list members...'; | |
const listResponse = await gmFetch(`${pdsUrl}xrpc/app.bsky.graph.getList?${params}`, { | |
method: 'GET', | |
headers: { | |
'Accept': 'application/json' | |
} | |
}); | |
const listData = await listResponse.json(); | |
allUsers = allUsers.concat(listData.items); | |
cursor = listData.cursor; | |
} while (cursor); | |
const totalUsers = allUsers.length; | |
for (let i = 0; i < allUsers.length; i++) { | |
const item = allUsers[i]; | |
const userDid = item.subject.did; | |
elements.progressText.textContent = `Adding user ${item.subject.handle} (${i + 1}/${totalUsers})...`; | |
const addToListBody = { | |
collection: "app.bsky.graph.listitem", | |
repo: getUserData().did, | |
record: { | |
subject: userDid, | |
list: selectedListUri, | |
createdAt: new Date().toISOString(), | |
"$type": "app.bsky.graph.listitem" | |
} | |
}; | |
try { | |
await gmFetch(`${pdsUrl}xrpc/com.atproto.repo.createRecord`, { | |
method: "POST", | |
headers: { | |
'Content-Type': 'application/json', | |
'Accept': 'application/json' | |
}, | |
body: JSON.stringify(addToListBody) | |
}); | |
} catch (error) { | |
console.error(`Failed to add ${item.subject.handle} to list:`, error); | |
showErrorToast(`Failed to add ${item.subject.handle} to list`); | |
} | |
} | |
elements.spinner.classList.add('bspl-hidden'); | |
elements.progressText.textContent = `✅ Finished adding ${totalUsers} users to list`; | |
setTimeout(() => { | |
elements.progressContainer.style.display = 'none'; | |
elements.button.disabled = false; | |
}, 3000); | |
} catch (error) { | |
elements.spinner.classList.add('bspl-hidden'); | |
elements.progressText.textContent = `❌ Error: ${error.message}`; | |
showErrorToast(error.message); | |
console.error('Error processing starter pack:', error); | |
setTimeout(() => { | |
elements.progressContainer.style.display = 'none'; | |
elements.button.disabled = false; | |
}, 3000); | |
} | |
} | |
initialize(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment