Last active
December 31, 2024 19:38
-
-
Save cktang88/84f1f94e8b18ae769cab8648ae306d46 to your computer and use it in GitHub Desktop.
no-twitter-bookmarks
This file contains hidden or 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
/* | |
Hides Twitter's bookmarking button, prevents "hoarding" bookmarks. | |
However can still access past bookmarks and unbookmark the ones you have already. | |
Using data-testid b/c it also hides the bookmark btn on a single tweet | |
*/ | |
button[data-testid="bookmark"] { | |
display: none !important; | |
} |
This file contains hidden or 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
// adds a "copy" button that copies text + image of a tweet into clipboard | |
document.addEventListener('DOMContentLoaded', () => { | |
// CSS for our copy button + click animation | |
const style = document.createElement('style'); | |
style.textContent = ` | |
.my-copy-tweet-btn { | |
background: none; | |
border: none; | |
outline: none; | |
color: #71767B; | |
display: inline-flex; | |
align-items: center; | |
cursor: pointer; | |
margin-left: 8px; | |
} | |
.my-copy-tweet-btn:hover { | |
color: #1d9bf0; | |
} | |
.my-copy-tweet-btn svg { | |
fill: currentColor; | |
width: 18px; | |
height: 18px; | |
} | |
@keyframes copyButtonClick { | |
0% { transform: scale(1); } | |
40% { transform: scale(1.3); } | |
100% { transform: scale(1); } | |
} | |
.my-copy-tweet-btn.clicked { | |
animation: copyButtonClick 0.3s ease-in-out; | |
} | |
`; | |
document.head.appendChild(style); | |
async function urlToBlob(url) { | |
const response = await fetch(url); | |
return response.blob(); | |
} | |
function blobToBase64(blob) { | |
return new Promise((resolve, reject) => { | |
const reader = new FileReader(); | |
reader.onload = () => resolve(reader.result); | |
reader.onerror = reject; | |
reader.readAsDataURL(blob); | |
}); | |
} | |
async function generateHtmlWithImages(tweetText, imageUrls) { | |
let html = `<p>${tweetText}</p>`; | |
for (const url of imageUrls) { | |
const blob = await urlToBlob(url); | |
const base64 = await blobToBase64(blob); | |
html += `<img src="${base64}" /><br/>`; | |
} | |
return html; | |
} | |
// 4) Main copy logic | |
async function copyTweetContent(tweetEl, buttonEl) { | |
// A) --- Gather the main tweet text | |
const textEl = tweetEl.querySelector('[data-testid="tweetText"]'); | |
const tweetText = textEl?.innerText ?? ''; | |
// B) --- Extract display name, handle, date | |
let displayName = ''; | |
let handle = ''; | |
let tweetDate = ''; | |
// i) The user name container (display name + handle are often in same block) | |
// Usually `[data-testid="User-Name"]` has display name in <span> and handle in another <span>, or in adjacent elements. | |
// We'll do a minimal approach: find the first link in `[data-testid="User-Name"]` and parse text. | |
const userNameBlock = tweetEl.querySelector('[data-testid="User-Name"]'); | |
if (userNameBlock) { | |
// The display name often is the first text, and the handle might be an adjacent link or span. | |
// Different Twitter versions can differ, so let's just parse them carefully: | |
// (a) Display name is often in a child <span> that doesn't contain '@'. | |
const nameSpan = [...userNameBlock.querySelectorAll('span')] | |
.find(sp => sp.innerText && !sp.innerText.startsWith('@')); | |
displayName = nameSpan?.innerText.trim() ?? ''; | |
// (b) The handle is often in a child <span> that DOES contain '@' | |
const handleSpan = [...userNameBlock.querySelectorAll('span')] | |
.find(sp => sp.innerText && sp.innerText.startsWith('@')); | |
handle = handleSpan?.innerText.trim() ?? ''; | |
} | |
// ii) The date is typically in a <time> element | |
const timeEl = tweetEl.querySelector('time'); | |
if (timeEl) { | |
// Grab the raw datetime attribute (e.g. "2024-12-31T17:35:20.000Z") | |
const dateStr = timeEl.getAttribute('datetime'); | |
if (dateStr) { | |
// Convert it into a Date object | |
const dateObj = new Date(dateStr); | |
// Format as you like, for example: "Dec 31, 2024, 9:35 AM" | |
tweetDate = dateObj.toLocaleString([], { | |
year: 'numeric', | |
month: 'short', | |
day: '2-digit', | |
hour: 'numeric', | |
minute: '2-digit', | |
}); | |
} | |
} | |
// C) --- Build the appended user info line | |
// Something like: | |
// — DisplayName (@handle) on Dec 31, 2024 | |
// If data is missing, it simply won't appear. | |
let userInfoLine = '—'; | |
if (displayName) userInfoLine += ` ${displayName}`; | |
if (handle) userInfoLine += ` (${handle})`; | |
if (tweetDate) userInfoLine += ` on ${tweetDate}`; | |
// D) --- Gather images | |
const allImgs = [...tweetEl.querySelectorAll('img')]; | |
// Filter for real tweet images (pbs.twimg.com) | |
const mediaImgs = allImgs.filter(img => /pbs\.twimg\.com\/media\//.test(img.src)); | |
const imageUrls = mediaImgs.map(img => img.src); | |
// E) --- Prepare text + HTML | |
// Append the userInfoLine to the bottom | |
const plainText = tweetText + '\n\n' + userInfoLine; | |
// Build HTML snippet | |
let htmlContent = await generateHtmlWithImages(plainText, imageUrls); | |
// F) --- Single ClipboardItem | |
const plainTextBlob = new Blob([plainText], { type: 'text/plain' }); | |
const htmlBlob = new Blob([htmlContent], { type: 'text/html' }); | |
const singleItem = new ClipboardItem({ | |
'text/plain': plainTextBlob, | |
'text/html': htmlBlob, | |
}); | |
await navigator.clipboard.write([singleItem]); | |
// G) --- Small CSS "pop" animation | |
buttonEl.classList.add('clicked'); | |
setTimeout(() => buttonEl.classList.remove('clicked'), 300); | |
} | |
function addCopyButtonsToTweets() { | |
const tweets = document.querySelectorAll('article[data-testid="tweet"]'); | |
tweets.forEach(tweetEl => { | |
const likeButton = tweetEl.querySelector('[data-testid="like"]'); | |
if (!likeButton) return; | |
if (tweetEl.querySelector('.my-copy-tweet-btn')) return; | |
const copyIconSVG = ` | |
<svg viewBox="0 0 24 24" aria-hidden="true"> | |
<g> | |
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8 | |
c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 | |
2-2V7c0-1.1-.9-2-2-2z"></path> | |
</g> | |
</svg> | |
`; | |
const copyBtn = document.createElement('button'); | |
copyBtn.className = 'my-copy-tweet-btn'; | |
copyBtn.innerHTML = copyIconSVG; | |
copyBtn.onclick = (e) => { | |
e.stopPropagation(); | |
copyTweetContent(tweetEl, copyBtn).catch(err => console.error(err)); | |
}; | |
likeButton.parentNode.parentNode.insertBefore(copyBtn, likeButton.nextSibling); | |
}); | |
} | |
// Try MutationObserver | |
try { | |
const observer = new MutationObserver(() => { | |
addCopyButtonsToTweets(); | |
}); | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true | |
}); | |
} catch (err) { | |
console.warn('MutationObserver not working—fallback to setInterval:', err); | |
// Fallback approach: periodically scan for new tweets | |
setInterval(() => { | |
addCopyButtonsToTweets(); | |
}, 2000); | |
} | |
// Run once initially | |
addCopyButtonsToTweets(); | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
great as an Arc boost, or can just edit page CSS in devtools