Skip to content

Instantly share code, notes, and snippets.

@b0o
Last active February 18, 2025 02:40
Show Gist options
  • Save b0o/22521cf73f6c7a5aacbb8cd0f29197ac to your computer and use it in GitHub Desktop.
Save b0o/22521cf73f6c7a5aacbb8cd0f29197ac to your computer and use it in GitHub Desktop.
Bluesky Starter Pack List Adder

Install UserScript Follow Me

2024-12-18_21-22-05_region_re.mp4
// ==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