// ==UserScript==
// @name TweetXer
// @namespace https://github.com/lucahammer/tweetXer/
// @version 0.9.3
// @description Delete all your Tweets for free.
// @author Luca,dbort,pReya,Micolithe,STrRedWolf
// @license NoHarm-draft
// @match https://x.com/*
// @match https://mobile.x.com/*
// @match https://twitter.com/*
// @match https://mobile.twitter.com/*
// @icon https://www.google.com/s2/favicons?domain=twitter.com
// @grant none
// @run-at document-idle
// @downloadURL https://update.greasyfork.org/scripts/476062/TweetXer.user.js
// @updateURL https://update.greasyfork.org/scripts/476062/TweetXer.meta.js
// @supportURL https://github.com/lucahammer/tweetXer/issues
// ==/UserScript==
(function () {
let TweetsXer = {
version: '0.9.3',
TweetCount: 0,
dId: "exportUpload",
tIds: [],
tId: "",
ratelimitreset: 0,
more: '[data-testid="tweet"] [data-testid="caret"]',
skip: 0,
total: 0,
dCount: 0,
deleteURL: '/i/api/graphql/VaenaVgh5q5ih7kvyVjgtg/DeleteTweet',
unfavURL: '/i/api/graphql/ZYKSe-w7KEslx3JhSIk5LA/UnfavoriteTweet',
deleteMessageURL: '/i/api/graphql/BJ6DtxA2llfjnRoRjaiIiw/DMMessageDeleteMutation',
deleteConvoURL: '/i/api/1.1/dm/conversation/USER_ID-CONVERSATION_ID/delete.json',
deleteDMsOneByOne: false,
username: '',
action: '',
bookmarksURL: '/i/api/graphql/L7vvM2UluPgWOW4GDvWyvw/Bookmarks?',
bookmarks: [],
bookmarksNext: '',
baseUrl: 'https://x.com',
authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
ct0: false,
transaction_id: '',
async init() {
this.baseUrl = `https://${window.location.hostname}`
this.updateTransactionId()
this.createUploadForm()
await this.getTweetCount()
this.ct0 = this.getCookie('ct0')
this.username = document.location.href.split('/')[3].replace('#', '')
},
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
},
getCookie(name) {
const match = `; ${document.cookie}`.match(`;\\s*${name}=([^;]+)`)
return match ? match[1] : null
},
updateTransactionId() {
// random string
this.transaction_id = [...crypto.getRandomValues(new Uint8Array(95))]
.map((x, i) => (i = x / 255 * 61 | 0, String.fromCharCode(i + (i > 9 ? i > 35 ? 61 : 55 : 48)))).join``
},
updateTitle(text) {
document.getElementById('tweetsXer_title').textContent = text
},
updateInfo(text) {
document.getElementById("info").textContent = text
},
createProgressBar() {
const progressbar = document.createElement("progress")
progressbar.id = "progressbar"
progressbar.value = this.dCount
progressbar.max = this.total
progressbar.style = 'width:100%'
document.getElementById(this.dId).appendChild(progressbar)
},
updateProgressBar() {
document.getElementById('progressbar').value = this.dCount
this.updateInfo(`${this.dCount} deleted. ${this.tId}`)
},
processFile() {
const tn = document.getElementById(`${TweetsXer.dId}_file`)
if (tn.files && tn.files[0]) {
let fr = new FileReader()
fr.onloadend = function (evt) {
// window.YTD.tweet_headers.part0
// window.YTD.tweets.part0
// window.YTD.like.part0
// window.YTD.direct_message_headers.part0
let cutpoint = evt.target.result.indexOf('= ')
let filestart = evt.target.result.slice(0, cutpoint)
let json = JSON.parse(evt.target.result.slice(cutpoint + 1))
if (filestart.includes('.tweet_headers.')) {
console.log('File contains Tweets.')
TweetsXer.action = 'untweet'
TweetsXer.tIds = json.map((x) => x.tweet.tweet_id)
} else if (filestart.includes('.tweets.') || filestart.includes('.tweet.')) {
console.log('File contains Tweets.')
TweetsXer.action = 'untweet'
TweetsXer.tIds = json.map((x) => x.tweet.id_str)
} else if (filestart.includes('.like.')) {
console.log('File contains Favs.')
TweetsXer.action = 'unfav'
TweetsXer.tIds = json.map((x) => x.like.tweetId)
}
else if (
filestart.includes('.direct_message_headers.')
|| filestart.includes('.direct_message_group_headers.')
|| filestart.includes('.direct_messages.')
|| filestart.includes('.direct_message_groups.')) {
console.log('File contains Direct Messages.')
TweetsXer.action = 'undm'
if (this.deleteDMsOneByOne) {
TweetsXer.tIds = json.map((c) => c.dmConversation.messages.map((m) => m.messageCreate ? m.messageCreate.id : 0))
TweetsXer.tIds = TweetsXer.tIds.flat()
TweetsXer.tIds = TweetsXer.tIds.filter((i) => i != 0)
}
else {
TweetsXer.tIds = json.map((c) => c.dmConversation.conversationId)
}
} else {
TweetsXer.updateInfo('File content not recognized. Please use a file from the Twitter data export.')
console.log('File content not recognized. Please use a file from the Twitter data export.')
}
if (TweetsXer.action.length > 0) {
TweetsXer.total = TweetsXer.tIds.length
document.getElementById(`${TweetsXer.dId}_file`).remove()
TweetsXer.createProgressBar()
}
if (TweetsXer.action == 'untweet') {
if (document.getElementById('skipCount').value.length < 1) {
// If there is no amount set to skip, automatically try to skip the amount
// that has been deleted already. Difference of Tweeets in file to count on profile
// 5% tolerance to prevent skipping too much
TweetsXer.skip = TweetsXer.total - TweetsXer.TweetCount - parseInt(TweetsXer.total / 20)
TweetsXer.skip = Math.max(0, TweetsXer.skip)
}
else {
TweetsXer.skip = document.getElementById('skipCount').value
}
console.log(`Skipping oldest ${TweetsXer.skip} Tweets. Use advanced options to manually set how many to skip. Enter 0 to prevent the automatic calculation.`)
TweetsXer.tIds.reverse()
TweetsXer.tIds = TweetsXer.tIds.slice(TweetsXer.skip)
TweetsXer.dCount = TweetsXer.skip
TweetsXer.tIds.reverse()
TweetsXer.updateTitle(`TweetXer: Deleting ${TweetsXer.total} Tweets`)
TweetsXer.deleteTweets()
} else if (TweetsXer.action == 'unfav') {
TweetsXer.skip = document.getElementById('skipCount').value.length > 0 ? document.getElementById('skipCount').value : 0
console.log(`Skipping oldest ${TweetsXer.skip} Tweets`)
TweetsXer.tIds = TweetsXer.tIds.slice(TweetsXer.skip)
TweetsXer.dCount = TweetsXer.skip
TweetsXer.tIds.reverse()
TweetsXer.updateTitle(`TweetXer: Deleting ${TweetsXer.total} Favs`)
TweetsXer.deleteFavs()
} else if (TweetsXer.action == 'undm') {
TweetsXer.skip = document.getElementById('skipCount').value.length > 0 ? document.getElementById('skipCount').value : 0
console.log(`Skipping ${TweetsXer.skip} messages/convos`)
TweetsXer.tIds = TweetsXer.tIds.slice(TweetsXer.skip)
TweetsXer.dCount = TweetsXer.skip
TweetsXer.tIds.reverse()
if (this.deleteDMsOneByOne) {
TweetsXer.updateTitle(`TweetXer: Deleting ${TweetsXer.total} DMs`)
TweetsXer.deleteDMs()
}
else {
TweetsXer.updateTitle(`TweetXer: Deleting ${TweetsXer.total} DM Conversations`)
TweetsXer.deleteConvos()
}
}
else {
TweetsXer.updateTitle(`TweetXer: Please try a different file`)
}
}
fr.readAsText(tn.files[0])
}
},
createUploadForm() {
const h2Class = document.querySelectorAll("h2")[1]?.getAttribute("class") || ""
const div = document.createElement("div")
div.id = this.dId
if (document.getElementById(this.dId)) { document.getElementById(this.dId).remove() }
div.innerHTML = `
`
document.body.insertBefore(div, document.body.firstChild)
document.getElementById("toggleAdvanced").addEventListener("click", (() => {
const adv = document.getElementById('advanced')
if (adv.style.display == 'none') {
adv.style.display = 'block'
} else {
adv.style.display = 'none'
}
}))
document.getElementById(`${this.dId}_file`).addEventListener("change", this.processFile, false)
document.getElementById("exportBookmarks").addEventListener("click", this.exportBookmarks, false)
document.getElementById("slowDelete").addEventListener("click", this.slowDelete, false)
document.getElementById("unfollowEveryone").addEventListener("click", this.unfollow, false)
document.getElementById("removeTweetXer").addEventListener("click", this.removeTweetXer, false)
},
async exportBookmarks() {
TweetsXer.updateTitle('TweetXer: Exporting bookmarks')
let variables = ''
while (TweetsXer.bookmarksNext.length > 0 || TweetsXer.bookmarks.length == 0) {
if (TweetsXer.bookmarksNext.length > 0) {
variables = `{"count":20,"cursor":"${TweetsXer.bookmarksNext}","includePromotedContent":false}`
} else variables = '{"count":20,"includePromotedContent":false}'
let response = await fetch(TweetsXer.baseUrl + TweetsXer.bookmarksURL + new URLSearchParams({
variables: variables,
features: '{"graphql_timeline_v2_bookmark_timeline":true,"rweb_tipjar_consumption_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"rweb_video_timestamps_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_enhance_cards_enabled":false}'
}), {
"headers": {
"authorization": TweetsXer.authorization,
"content-type": "application/json",
"x-client-transaction-id": TweetsXer.transaction_id,
"x-csrf-token": TweetsXer.ct0,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
},
"referrer": `${TweetsXer.baseUrl}/i/bookmarks`,
"referrerPolicy": "strict-origin-when-cross-origin",
"method": "GET",
"mode": "cors",
"credentials": "include"
})
if (response.status == 200) {
let data = await response.json()
data.data.bookmark_timeline_v2.timeline.instructions[0].entries.forEach((item) => {
if (item.entryId.includes('tweet')) {
TweetsXer.dCount++
TweetsXer.bookmarks.push(item.content.itemContent.tweet_results.result)
} else if (item.entryId.includes('cursor-bottom')) {
if (TweetsXer.bookmarksNext != item.content.value) {
TweetsXer.bookmarksNext = item.content.value
} else {
TweetsXer.bookmarksNext = ''
}
}
})
//document.getElementById('progressbar').setAttribute('value', TweetsXer.dCount)
TweetsXer.updateInfo(`${TweetsXer.dCount} Bookmarks collected`)
} else {
console.log(response)
}
if (!response.headers.get('x-rate-limit-remaining') && response.headers.get('x-rate-limit-remaining') < 1) {
console.log('rate limit hit')
TweetsXer.ratelimitreset = response.headers.get('x-rate-limit-reset')
let sleeptime = TweetsXer.ratelimitreset - Math.floor(Date.now() / 1000)
while (sleeptime > 0) {
sleeptime = TweetsXer.ratelimitreset - Math.floor(Date.now() / 1000)
TweetsXer.updateInfo(`Ratelimited. Waiting ${sleeptime} seconds. ${TweetsXer.dCount} deleted.`)
await TweetsXer.sleep(1000)
}
}
}
let download = new Blob([JSON.stringify(TweetsXer.bookmarks)], {
type: 'text/plain'
})
let bookmarksDownload = document.createElement("a")
bookmarksDownload.id = 'bookmarksDownload'
bookmarksDownload.innerText = 'Download bookmarks'
bookmarksDownload.href = window.URL.createObjectURL(download)
bookmarksDownload.download = 'twitter-bookmarks.json'
document.getElementById('advanced').appendChild(bookmarksDownload)
TweetsXer.updateTitle('TweetXer')
},
async sendRequest(
url,
body = `{\"variables\":{\"tweet_id\":\"${TweetsXer.tId}\",\"dark_request\":false},\"queryId\":\"${url.split('/')[6]}\"}`
) {
return new Promise(async (resolve) => {
try {
let response = await fetch(url, {
"headers": {
"authorization": TweetsXer.authorization,
"content-type": "application/json",
"x-client-transaction-id": TweetsXer.transaction_id,
"x-csrf-token": TweetsXer.ct0,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session"
},
"referrer": `${TweetsXer.baseUrl}/${TweetsXer.username}/with_replies`,
"referrerPolicy": "strict-origin-when-cross-origin",
"body": body,
"method": "POST",
"mode": "cors",
"credentials": "include",
"signal": AbortSignal.timeout(5000)
})
if (response.status == 200) {
TweetsXer.dCount++
TweetsXer.updateProgressBar()
if (response.headers.get('x-rate-limit-remaining') != null && response.headers.get('x-rate-limit-remaining') < 1) {
console.log('rate limit hit')
console.log(response.headers.get('x-rate-limit-remaining'))
TweetsXer.ratelimitreset = response.headers.get('x-rate-limit-reset')
let sleeptime = TweetsXer.ratelimitreset - Math.floor(Date.now() / 1000)
while (sleeptime > 0) {
sleeptime = TweetsXer.ratelimitreset - Math.floor(Date.now() / 1000)
TweetsXer.updateInfo(`Ratelimited. Waiting ${sleeptime} seconds. ${TweetsXer.dCount} deleted.`)
await TweetsXer.sleep(1000)
}
resolve('deleted and waiting')
}
else {
resolve('deleted')
}
}
else if (response.status == 429) {
TweetsXer.tIds.push(TweetsXer.tId)
console.log('Received status code 429. Waiting for 1 second before trying again.')
await TweetsXer.sleep(1000)
}
else {
console.log(response)
}
} catch (error) {
if (error.Name === 'AbortError') {
TweetsXer.tIds.push(TweetsXer.tId)
console.log('Request timeout.')
let sleeptime = 15
while (sleeptime > 0) {
sleeptime--
TweetsXer.updateInfo(`Ratelimited. Waiting ${sleeptime} seconds. ${TweetsXer.dCount} deleted.`)
await TweetsXer.sleep(1000)
}
resolve('error')
}
}
})
},
async deleteTweets() {
while (this.tIds.length > 0) {
this.tId = this.tIds.pop()
await this.sendRequest(this.baseUrl + this.deleteURL)
}
this.tId = ''
this.updateProgressBar()
},
async deleteFavs() {
this.updateTitle('TweetXer: Deleting Favs')
// 500 unfavs per 15 Minutes
// x-rate-limit-remaining
// x-rate-limit-reset
while (this.tIds.length > 0) {
this.tId = this.tIds.pop()
await this.sendRequest(this.baseUrl + this.unfavURL)
}
this.tId = ''
this.updateTitle('TweetXer')
this.updateProgressBar()
},
async deleteDMs() {
while (this.tIds.length > 0) {
this.tId = this.tIds.pop()
await this.sendRequest(
this.baseUrl + this.deleteMessageURL,
body = `{\"variables\":{\"messageId\":\"${this.tId}\"},\"requestId\":\""}`
)
}
this.tId = ''
this.updateProgressBar()
},
async deleteConvos() {
while (this.tIds.length > 0) {
this.tId = this.tIds.pop()
url = this.baseUrl + this.deleteConvoURL.replace('USER_ID-CONVERSATION_ID', this.tId)
let response = await fetch(url, {
"headers": {
"authorization": TweetsXer.authorization,
"content-type": "application/x-www-form-urlencoded",
"x-client-transaction-id": TweetsXer.transaction_id,
"x-csrf-token": TweetsXer.ct0,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session"
},
"referrer": `${TweetsXer.baseUrl}/messages`,
"body": 'dm_secret_conversations_enabled=false&krs_registration_enabled=true&cards_platform=Web-12&include_cards=1&include_ext_alt_text=true&include_ext_limited_action_results=true&include_quote_count=true&include_reply_count=1&tweet_mode=extended&include_ext_views=true&dm_users=false&include_groups=true&include_inbox_timelines=true&include_ext_media_color=true&supports_reactions=true&supports_edit=true&include_conversation_info=true',
"method": "POST",
"mode": "cors",
"credentials": "include",
"signal": AbortSignal.timeout(5000)
})
if (response.status == 204) {
TweetsXer.dCount++
TweetsXer.updateProgressBar()
if (response.headers.get('x-rate-limit-remaining') != null && response.headers.get('x-rate-limit-remaining') < 1) {
console.log('rate limit hit')
console.log(response.headers.get('x-rate-limit-remaining'))
TweetsXer.ratelimitreset = response.headers.get('x-rate-limit-reset')
let sleeptime = TweetsXer.ratelimitreset - Math.floor(Date.now() / 1000)
while (sleeptime > 0) {
sleeptime = TweetsXer.ratelimitreset - Math.floor(Date.now() / 1000)
TweetsXer.updateInfo(`Ratelimited. Waiting ${sleeptime} seconds. ${TweetsXer.dCount} deleted.`)
await TweetsXer.sleep(1000)
}
}
await TweetsXer.sleep(Math.floor(Math.random() * 200)) // send requests slightly slower and with random intervals
}
else if (response.status == 429 || response.status == 420) {
TweetsXer.tIds.push(TweetsXer.tId)
console.log(`Received status code ${response.status}. Waiting before trying again.`)
let sleeptime = 60 * 5 // is that enough?
while (sleeptime > 0) {
sleeptime--
TweetsXer.updateInfo(`Ratelimited. Waiting ${sleeptime} seconds. ${TweetsXer.dCount} deleted.`)
await TweetsXer.sleep(1000)
}
}
else {
console.log(response)
}
}
this.tId = ''
this.updateProgressBar()
},
async getTweetCount() {
await waitForElemToExist('header')
await TweetsXer.sleep(1000)
if (!document.querySelector('[data-testid="UserName"]')) {
if (document.querySelector('[aria-label="Back"]')) {
await TweetsXer.sleep(200)
document.querySelector('[aria-label="Back"]').click()
await TweetsXer.sleep(1000)
}
else if (document.querySelector('[data-testid="app-bar-back"]')) {
document.querySelector('[data-testid="app-bar-back"]').click()
await TweetsXer.sleep(1000)
}
if (document.querySelector('[data-testid="AppTabBar_Profile_Link"]')) {
await TweetsXer.sleep(200)
document.querySelector('[data-testid="AppTabBar_Profile_Link"]').click()
}
else if (document.querySelector('[data-testid="DashButton_ProfileIcon_Link"]')) {
await TweetsXer.sleep(100)
document.querySelector('[data-testid="DashButton_ProfileIcon_Link"]').click()
await TweetsXer.sleep(1000)
document.querySelector('[data-testid="icon"').nextElementSibling.click()
}
await waitForElemToExist('[data-testid="UserName"]')
}
await TweetsXer.sleep(1000)
function extractTweetCount(selector) {
const element = document.querySelector(selector)
if (!element) return null
const match = element.textContent.match(/((\d|,|\.|K)+) (\w+)$/)
if (!match) return null
return match[1]
.replace(/\.(\d+)K/, '$1'.padEnd(4, '0'))
.replace('K', '000')
.replace(',', '')
.replace('.', '')
}
try {
TweetsXer.TweetCount = extractTweetCount('[data-testid="primaryColumn"]>div>div>div')
if (!TweetsXer.TweetCount) {
TweetsXer.TweetCount = extractTweetCount('[data-testid="TopNavBar"]>div>div')
}
if (!TweetsXer.TweetCount) {
console.log("Wasn't able to find Tweet count on profile. Setting it to 1 million.")
TweetsXer.TweetCount = 1000000
}
} catch (error) {
console.log("Wasn't able to find Tweet count on profile. Setting it to 1 million.")
TweetsXer.TweetCount = 1000000 // prevents Tweets from being skipped because if tweet count of 0
}
this.updateInfo('Select your tweet-headers.js from your Twitter Data Export to start the deletion of all your Tweets.')
console.log(TweetsXer.TweetCount + " Tweets on profile.")
console.log("You can close the console now to reduce the memory usage.")
console.log("Reopen the console if there are issues to see if an error shows up.")
},
async slowDelete() {
//document.getElementById("toggleAdvanced").click()
document.getElementById('start').remove()
TweetsXer.total = TweetsXer.TweetCount
TweetsXer.createProgressBar()
document.querySelectorAll('[data-testid="ScrollSnap-List"] a')[1].click()
await TweetsXer.sleep(2000)
let unretweet, confirmURT, caret, menu, confirmation
const more = '[data-testid="tweet"] [data-testid="caret"]'
while (document.querySelectorAll(more).length > 0) {
// give the Tweets a chance to load; increase/decrease if necessary
// afaik the limit is 50 requests per minute
await TweetsXer.sleep(1200)
// hide recommended profiles and stuff
document.querySelectorAll('section [data-testid="cellInnerDiv"]>div>div>div').forEach(x => x.remove())
document.querySelectorAll('section [data-testid="cellInnerDiv"]>div>div>[role="link"]').forEach(x => x.remove())
document.querySelector(more).scrollIntoView({
'behavior': 'smooth'
})
// if it is a Retweet, unretweet it
unretweet = document.querySelector('[data-testid="unretweet"]')
if (unretweet) {
unretweet.click()
confirmURT = await waitForElemToExist('[data-testid="unretweetConfirm"]')
confirmURT.click()
}
// delete Tweet
else {
caret = await waitForElemToExist(more)
caret.click()
menu = await waitForElemToExist('[role="menuitem"]')
if (menu.textContent.includes('@')) {
// don't unfollow people (because their Tweet is the reply tab)
caret.click()
document.querySelector('[data-testid="tweet"]').remove()
} else {
menu.click()
confirmation = await waitForElemToExist('[data-testid="confirmationSheetConfirm"]')
if (confirmation) confirmation.click()
}
}
TweetsXer.dCount++
TweetsXer.updateProgressBar()
// print to the console how many Tweets already got deleted
// Change the 100 to how often you want an update.
// 10 for every 10th Tweet, 1 for every Tweet, 100 for every 100th Tweet
if (TweetsXer.dCount % 100 == 0) console.log(`${new Date().toUTCString()} Deleted ${TweetsXer.dCount} Tweets`)
}
console.log('No Tweets left. Please reload to confirm.')
},
async unfollow() {
//document.getElementById("toggleAdvanced").click()
let unfollowCount = 0
let next_unfollow, menu
document.querySelector('[href$="/following"]').click()
await TweetsXer.sleep(1200)
const accounts = '[data-testid="UserCell"]'
while (document.querySelectorAll('[data-testid="UserCell"] [data-testid$="-unfollow"]').length > 0) {
next_unfollow = document.querySelectorAll(accounts)[0]
next_unfollow.scrollIntoView({
'behavior': 'smooth'
})
next_unfollow.querySelector('[data-testid$="-unfollow"]').click()
menu = await waitForElemToExist('[data-testid="confirmationSheetConfirm"]')
menu.click()
next_unfollow.remove()
unfollowCount++
if (unfollowCount % 10 == 0) console.log(`${new Date().toUTCString()} Unfollowed ${unfollowCount} accounts`)
await TweetsXer.sleep(Math.floor(Math.random() * 200))
}
console.log('No accounts left. Please reload to confirm.')
},
removeTweetXer() {
document.getElementById('exportUpload').remove()
}
}
const waitForElemToExist = async (selector) => {
const elem = document.querySelector(selector)
if (elem) return elem
return new Promise(resolve => {
const observer = new MutationObserver(() => {
const elem = document.querySelector(selector)
if (elem) {
resolve(elem)
observer.disconnect()
}
})
observer.observe(document.body, {
subtree: true,
childList: true,
})
})
}
TweetsXer.init()
})()