Gives you more control over Twitter and adds missing features and UI improvements
// ==UserScript== // @name Control Panel for Twitter // @description Gives you more control over Twitter and adds missing features and UI improvements // @icon https://raw.githubusercontent.com/insin/control-panel-for-twitter/master/icons/icon32.png // @namespace https://github.com/insin/control-panel-for-twitter/ // @match https://twitter.com/* // @match https://mobile.twitter.com/* // @match https://x.com/* // @match https://mobile.x.com/* // @run-at document-start // @version 190 // ==/UserScript== void function() { // Patch XMLHttpRequest to modify requests const XMLHttpRequest_open = XMLHttpRequest.prototype.open XMLHttpRequest.prototype.open = function(method, url) { if (config.sortReplies != 'relevant' && !userSortedReplies && url.includes('/TweetDetail?')) { let request = new URL(url) let params = new URLSearchParams(request.search) let variables = JSON.parse(decodeURIComponent(params.get('variables'))) variables.rankingMode = { liked: 'Likes', recent: 'Recency', }[config.sortReplies] params.set('variables', JSON.stringify(variables)) url = `${request.origin}${request.pathname}?${params.toString()}` } return XMLHttpRequest_open.apply(this, [method, url]) } let debug = false /** @type {boolean} */ let desktop /** @type {boolean} */ let mobile let isSafari = navigator.userAgent.includes('Safari/') && !/Chrom(e|ium)\//.test(navigator.userAgent) /** @type {HTMLHtmlElement} */ let $html /** @type {HTMLElement} */ let $body /** @type {HTMLElement} */ let $reactRoot /** @type {string} */ let lang /** @type {string} */ let dir /** @type {boolean} */ let ltr //#region Default config /** * @type {import("./types").Config} */ const config = { debug: false, debugLogTimelineStats: false, // Shared addAddMutedWordMenuItem: true, alwaysUseLatestTweets: true, defaultToLatestSearch: false, disableHomeTimeline: false, disabledHomeTimelineRedirect: 'notifications', disableTweetTextFormatting: false, dontUseChirpFont: false, dropdownMenuFontWeight: true, fastBlock: true, followButtonStyle: 'monochrome', hideAdsNav: true, hideBlueReplyFollowedBy: false, hideBlueReplyFollowing: false, hideBookmarkButton: false, hideBookmarkMetrics: true, hideBookmarksNav: false, hideCommunitiesNav: false, hideComposeTweet: false, hideExplorePageContents: true, hideFollowingMetrics: true, hideForYouTimeline: true, hideGrokNav: true, hideGrokTweets: false, hideInlinePrompts: true, hideJobsNav: true, hideLikeMetrics: true, hideListsNav: false, hideMetrics: false, hideMonetizationNav: true, hideMoreTweets: true, hideNotifications: 'ignore', hideProfileRetweets: false, hideQuoteTweetMetrics: true, hideQuotesFrom: [], hideReplyMetrics: true, hideRetweetMetrics: true, hideSeeNewTweets: false, hideShareTweetButton: false, hid###bscriptions: true, hideTotalTweetsMetrics: true, hideTweetAnalyticsLinks: false, hideTwitterBlueReplies: false, hideTwitterBlueUpsells: true, hideUnavailableQuoteTweets: true, hideVerifiedNotificationsTab: true, hideViews: true, hideWhoToFollowEtc: true, listRetweets: 'ignore', mutableQuoteTweets: true, mutedQuotes: [], quoteTweets: 'ignore', redirectToTwitter: false, reducedInteractionMode: false, restoreLinkHeadlines: true, replaceLogo: true, restoreOtherInteractionLinks: false, restoreQuoteTweetsLink: true, retweets: 'separate', showBlueReplyFollowersCountAmount: '1000000', showBlueReplyFollowersCount: false, showBlueReplyVerifiedAccounts: false, showBookmarkButtonUnderFocusedTweets: true, sortReplies: 'relevant', tweakNewLayout: false, tweakQuoteTweetsPage: true, twitterBlueChecks: 'replace', unblurSensitiveContent: false, uninvertFollowButtons: true, // Experiments customCss: '', // Desktop only fullWidthContent: false, fullWidthMedia: true, hideAccountSwitcher: false, hideExploreNav: true, hideExploreNavWithSidebar: true, hideMessagesDrawer: true, hideProNav: true, hideSidebarContent: true, hideSpacesNav: false, hideTimelineTweetBox: false, hideToggleNavigation: false, navBaseFontSize: true, navDensity: 'default', showRelevantPeople: false, // Mobile only preventNextVideoAutoplay: true, hideMessagesBottomNavItem: false, } //#endregion //#region Locales /** * @type {Record<string, import("./types").Locale>} */ const locales = { 'ar-x-fm': { ADD_ANOTHER_TWEET: 'ضافة تغريدة أخرى', ADD_MUTED_WORD: 'اضافة كلمة مكتومة', GROK_ACTIONS: 'إجراءات Grok', HOME: 'الرئيسيّة', LIKES: 'الإعجابات', MOST_RELEVANT: 'الأكثر ملائمة', MUTE_THIS_CONVERSATION: 'كتم هذه المحادثه', POST_ALL: 'نشر الكل', POST_UNAVAILABLE: 'هذا المنشور غير متاح.', PROFILE_SUMMARY: 'ملخص الملف الشخصيّ', QUOTES: 'اقتباسات', QUOTE_TWEET: 'اقتباس التغريدة', QUOTE_TWEETS: 'تغريدات اقتباس', REPOST: 'إعادة النشر', REPOSTS: 'المنشورات المُعاد نشرها', RETWEET: 'إعادة التغريد', RETWEETED_BY: 'مُعاد تغريدها بواسطة', RETWEETS: 'إعادات التغريد', SHARED: 'مشترك', SHARED_TWEETS: 'التغريدات المشتركة', SHOW: 'إظهار', SHOW_MORE_REPLIES: 'عرض المزيد من الردود', SORT_REPLIES_BY: 'فرز الردود حسب', TURN_OFF_QUOTE_TWEETS: 'تعطيل تغريدات اقتباس', TURN_OFF_RETWEETS: 'تعطيل إعادة التغريد', TURN_ON_RETWEETS: 'تفعيل إعادة التغريد', TWEET: 'غرّدي', TWEETS: 'التغريدات', TWEET_ALL: 'تغريد الكل', TWEET_INTERACTIONS: 'تفاعلات التغريدة', TWEET_YOUR_REPLY: 'التغريد بردك', TWITTER: 'تويتر', UNDO_RETWEET: 'التراجع عن التغريدة', VIEW: 'عرض', WHATS_HAPPENING: 'ماذا يحدث؟', }, ar: { ADD_ANOTHER_TWEET: 'ضافة تغريدة أخرى', ADD_MUTED_WORD: 'اضافة كلمة مكتومة', GROK_ACTIONS: 'إجراءات Grok', HOME: 'الرئيسيّة', LIKES: 'الإعجابات', MOST_RELEVANT: 'الأكثر ملائمة', MUTE_THIS_CONVERSATION: 'كتم هذه المحادثه', POST_ALL: 'نشر الكل', POST_UNAVAILABLE: 'هذا المنشور غير متاح.', PROFILE_SUMMARY: 'ملخص الملف الشخصيّ', QUOTE: 'اقتباس', QUOTES: 'اقتباسات', QUOTE_TWEET: 'اقتباس التغريدة', QUOTE_TWEETS: 'تغريدات اقتباس', REPOST: 'إعادة النشر', REPOSTS: 'المنشورات المُعاد نشرها', RETWEET: 'إعادة التغريد', RETWEETED_BY: 'مُعاد تغريدها بواسطة', RETWEETS: 'إعادات التغريد', SHARED: 'مشترك', SHARED_TWEETS: 'التغريدات المشتركة', SHOW: 'إظهار', SHOW_MORE_REPLIES: 'عرض المزيد من الردود', SORT_REPLIES_BY: 'فرز الردود حسب', TURN_OFF_QUOTE_TWEETS: 'تعطيل تغريدات اقتباس', TURN_OFF_RETWEETS: 'تعطيل إعادة التغريد', TURN_ON_RETWEETS: 'تفعيل إعادة التغريد', TWEET: 'تغريد', TWEETS: 'التغريدات', TWEET_ALL: 'تغريد الكل', TWEET_INTERACTIONS: 'تفاعلات التغريدة', TWEET_YOUR_REPLY: 'التغريد بردك', UNDO_RETWEET: 'التراجع عن التغريدة', VIEW: 'عرض', WHATS_HAPPENING: 'ماذا يحدث؟', }, bg: { ADD_ANOTHER_TWEET: 'Добавяне на друг туит', ADD_MUTED_WORD: 'Добавяне на заглушена дума', GROK_ACTIONS: 'Действия, свързани с Grok', HOME: 'Начало', LIKES: 'Харесвания', MOST_RELEVANT: 'Най-подходящи', MUTE_THIS_CONVERSATION: 'Заглушаване на разговора', POST_ALL: 'Публикуване на всичко', POST_UNAVAILABLE: 'Тази публикация не е налична.', PROFILE_SUMMARY: 'Резюме на профила', QUOTE: 'Цитат', QUOTES: 'Цитати', QUOTE_TWEET: 'Цитиране на туита', QUOTE_TWEETS: 'Туитове с цитат', REPOST: 'Препубликуване', REPOSTS: 'Препубликувания', RETWEET: 'Ретуитване', RETWEETED_BY: 'Ретуитнат от', RETWEETS: 'Ретуитове', SHARED: 'Споделен', SHARED_TWEETS: 'Споделени туитове', SHOW: 'Показване', SHOW_MORE_REPLIES: 'Показване на още отговори', SORT_REPLIES_BY: 'Сортиране на отговорите', TURN_OFF_QUOTE_TWEETS: 'Изключване на туитове с цитат', TURN_OFF_RETWEETS: 'Изключване на ретуитовете', TURN_ON_RETWEETS: 'Включване на ретуитовете', TWEET: 'Туит', TWEETS: 'Туитове', TWEET_ALL: 'Туитване на всички', TWEET_INTERACTIONS: 'Интеракции с туит', TWEET_YOUR_REPLY: 'туит своя отговор', UNDO_RETWEET: 'Отмяна на ретуитването', VIEW: 'Преглед', WHATS_HAPPENING: 'Какво се случва?', }, bn: { ADD_ANOTHER_TWEET: 'অন্য টুইট যোগ করুন', ADD_MUTED_WORD: 'নীরব করা শব্দ যোগ করুন', GROK_ACTIONS: 'Grok কার্যকলাপ', HOME: 'হোম', LIKES: 'পছন্দ', MOST_RELEVANT: 'সবচেয়ে প্রাসঙ্গিক', MUTE_THIS_CONVERSATION: 'এই কথা-বার্তা নীরব করুন', POST_ALL: 'সবকটি পোস্ট করুন', POST_UNAVAILABLE: 'এই পোস্টটি অনুপলভ্য।', PROFILE_SUMMARY: 'প্রোফাইল সারসংক্ষেপ', QUOTE: 'উদ্ধৃতি', QUOTES: 'উদ্ধৃতিগুলো', QUOTE_TWEET: 'টুইট উদ্ধৃত করুন', QUOTE_TWEETS: 'টুইট উদ্ধৃতিগুলো', REPOST: 'রিপোস্ট', REPOSTS: 'রিপোস্ট', RETWEET: 'পুনঃটুইট', RETWEETED_BY: 'পুনঃ টুইট করেছেন', RETWEETS: 'পুনঃটুইটগুলো', SHARED: 'ভাগ করা', SHARED_TWEETS: 'ভাগ করা টুইটগুলি', SHOW: 'দেখান', SHOW_MORE_REPLIES: 'আরও উত্তর দেখান', SORT_REPLIES_BY: 'উত্তরগুলো এই হিসাবে বাছুন', TURN_OFF_QUOTE_TWEETS: 'উদ্ধৃতি টুইটগুলি বন্ধ করুন', TURN_OFF_RETWEETS: 'পুনঃ টুইটগুলি বন্ধ করুন', TURN_ON_RETWEETS: 'পুনঃ টুইটগুলি চালু করুন', TWEET: 'টুইট', TWEETS: 'টুইটগুলি', TWEET_ALL: 'সব টুইট করুন', TWEET_INTERACTIONS: 'টুইট ইন্টারেকশন', TWEET_YOUR_REPLY: 'আপনার উত্তর টুইট করুন', TWITTER: 'টুইটার', UNDO_RETWEET: 'পুনঃ টুইট পুর্বাবস্থায় ফেরান', VIEW: 'দেখুন', WHATS_HAPPENING: 'কি খবর?', }, ca: { ADD_ANOTHER_TWEET: 'Afegeix un altre tuit', ADD_MUTED_WORD: 'Afegeix una paraula silenciada', GROK_ACTIONS: 'Accions de Grok', HOME: 'Inici', LIKES: 'Agradaments', MOST_RELEVANT: 'El més rellevant', MUTE_THIS_CONVERSATION: 'Silencia la conversa', POST_ALL: 'Publica-ho tot', POST_UNAVAILABLE: 'Aquesta publicació no està disponible.', PROFILE_SUMMARY: 'R###m del perfil', QUOTE: 'Cita', QUOTES: 'Cites', QUOTE_TWEET: 'Cita el tuit', QUOTE_TWEETS: 'Tuits amb cita', REPOST: 'Republicació', REPOSTS: 'Republicacions', RETWEET: 'Retuit', RETWEETED_BY: 'Retuitat per', RETWEETS: 'Retuits', SHARED: 'Compartit', SHARED_TWEETS: 'Tuits compartits', SHOW: 'Mostra', SHOW_MORE_REPLIES: 'Mostra més respostes', SORT_REPLIES_BY: 'Ordena les respostes per', TURN_OFF_QUOTE_TWEETS: 'Desactiva els tuits amb cita', TURN_OFF_RETWEETS: 'Desactiva els retuits', TURN_ON_RETWEETS: 'Activa els retuits', TWEET: 'Tuita', TWEETS: 'Tuits', TWEET_ALL: 'Tuita-ho tot', TWEET_INTERACTIONS: 'Interaccions amb tuits', TWEET_YOUR_REPLY: 'Tuita la teva resposta', UNDO_RETWEET: 'Desfés el retuit', VIEW: 'Mostra', WHATS_HAPPENING: 'Què passa?', }, cs: { ADD_ANOTHER_TWEET: 'Přidat další Tweet', ADD_MUTED_WORD: 'Přidat slovo na seznam skrytých slov', GROK_ACTIONS: 'Akce funkce Grok', HOME: 'Hlavní stránka', LIKES: 'Lajky', MOST_RELEVANT: 'Nejvíce související', MUTE_THIS_CONVERSATION: 'Skrýt tuto konverzaci', POST_ALL: 'Postovat vše', POST_UNAVAILABLE: 'Tento post není dostupný.', PROFILE_SUMMARY: 'Souhrn profilu', QUOTE: 'Citace', QUOTES: 'Citace', QUOTE_TWEET: 'Citovat Tweet', QUOTE_TWEETS: 'Tweety s citací', REPOSTS: 'Reposty', RETWEET: 'Retweetnout', RETWEETED_BY: 'Retweetnuto uživateli', RETWEETS: 'Retweety', SHARED: 'Sdílený', SHARED_TWEETS: 'Sdílené tweety', SHOW: 'Zobrazit', SHOW_MORE_REPLIES: 'Zobrazit další odpovědi', SORT_REPLIES_BY: 'Odpovědi roztřiďte podle', TURN_OFF_QUOTE_TWEETS: 'Vypnout tweety s citací', TURN_OFF_RETWEETS: 'Vypnout retweety', TURN_ON_RETWEETS: 'Zapnout retweety', TWEET: 'Tweetovat', TWEETS: 'Tweety', TWEET_ALL: 'Tweetnout vše', TWEET_INTERACTIONS: 'Tweetovat interakce', TWEET_YOUR_REPLY: 'Tweetujte svou odpověď', UNDO_RETWEET: 'Zrušit Retweet', VIEW: 'Zobrazit', WHATS_HAPPENING: 'Co se děje?', }, da: { ADD_ANOTHER_TWEET: 'Tilføj endnu et Tweet', ADD_MUTED_WORD: 'Tilføj skjult ord', GROK_ACTIONS: 'Grok-handlinger', HOME: 'Forside', MOST_RELEVANT: 'Mest relevante', MUTE_THIS_CONVERSATION: 'Skjul denne samtale', POST_ALL: 'Post alle', POST_UNAVAILABLE: 'Denne post er ikke tilgængelig.', PROFILE_SUMMARY: 'Profilr###mé', QUOTE: 'Citat', QUOTES: 'Citater', QUOTE_TWEET: 'Citér Tweet', QUOTE_TWEETS: 'Citat-Tweets', RETWEETED_BY: 'Retweetet af', SHARED: 'Delt', SHARED_TWEETS: 'Delte tweets', SHOW: 'Vis', SHOW_MORE_REPLIES: 'Vis flere svar', SORT_REPLIES_BY: 'Sortér svar efter', TURN_OFF_QUOTE_TWEETS: 'Slå Citat-Tweets fra', TURN_OFF_RETWEETS: 'Slå Retweets fra', TURN_ON_RETWEETS: 'Slå Retweets til', TWEET_ALL: 'Tweet alt', TWEET_INTERACTIONS: 'Tweet-interaktioner', TWEET_YOUR_REPLY: 'Tweet dit svar', UNDO_RETWEET: 'Fortryd Retweet', VIEW: 'Vis', WHATS_HAPPENING: 'Hvad sker der?', }, de: { ADD_ANOTHER_TWEET: 'Weiteren Tweet hinzufügen', ADD_MUTED_WORD: 'Stummgeschaltetes Wort hinzufügen', GROK_ACTIONS: 'Grok-Aktionen', HOME: 'Startseite', LIKES: 'Gefällt mir', MOST_RELEVANT: 'Besonders relevant', MUTE_THIS_CONVERSATION: 'Diese Konversation stummschalten', POST_ALL: 'Alle posten', POST_UNAVAILABLE: 'Dieser Post ist nicht verfügbar.', PROFILE_SUMMARY: 'Kurzprofil', QUOTE: 'Zitat', QUOTES: 'Zitate', QUOTE_TWEET: 'Tweet zitieren', QUOTE_TWEETS: 'Zitierte Tweets', REPOST: 'Reposten', RETWEET: 'Retweeten', RETWEETED_BY: 'Retweetet von', SHARED: 'Geteilt', SHARED_TWEETS: 'Geteilte Tweets', SHOW: 'Anzeigen', SHOW_MORE_REPLIES: 'Mehr Antworten anzeigen', SORT_REPLIES_BY: 'Antworten sortieren nach', TURN_OFF_QUOTE_TWEETS: 'Zitierte Tweets ausschalten', TURN_OFF_RETWEETS: 'Retweets ausschalten', TURN_ON_RETWEETS: 'Retweets einschalten', TWEET: 'Twittern', TWEET_ALL: 'Alle twittern', TWEET_INTERACTIONS: 'Tweet-Interaktionen', TWEET_YOUR_REPLY: 'Twittere deine Antwort', UNDO_RETWEET: 'Retweet rückgängig machen', VIEW: 'Anzeigen', WHATS_HAPPENING: 'Was gibt’s Neues?', }, el: { ADD_ANOTHER_TWEET: 'Προσθήκη άλλου Tweet', ADD_MUTED_WORD: 'Προσθήκη λέξης σε σίγαση', GROK_ACTIONS: 'Δράσεις Grok', HOME: 'Αρχική σελίδα', LIKES: '"Μου αρέσει"', MOST_RELEVANT: 'Πιο σχετική', MUTE_THIS_CONVERSATION: 'Σίγαση αυτής της συζήτησης', POST_ALL: 'Δημοσίευση όλων', POST_UNAVAILABLE: 'Αυτή η ανάρτηση δεν είναι διαθέσιμη.', PROFILE_SUMMARY: ' Περίληψη προφίλ', QUOTE: 'Παράθεση', QUOTES: 'Παραθέσεις', QUOTE_TWEET: 'Παράθεση Tweet', QUOTE_TWEETS: 'Tweet με παράθεση', REPOST: 'Αναδημοσίευση', REPOSTS: 'Αναδημοσιεύσεις', RETWEETED_BY: 'Έγινε Retweet από', RETWEETS: 'Retweet', SHARED: 'Κοινόχρηστο', SHARED_TWEETS: 'Κοινόχρηστα Tweets', SHOW: 'Εμφάνιση', SHOW_MORE_REPLIES: 'Εμφάνιση περισσότερων απαντήσεων', SORT_REPLIES_BY: 'Ταξινόμηση απαντήσεων κατά', TURN_OFF_QUOTE_TWEETS: 'Απενεργοποίηση των tweet με παράθεση', TURN_OFF_RETWEETS: 'Απενεργοποίηση των Retweet', TURN_ON_RETWEETS: 'Ενεργοποίηση των Retweet', TWEETS: 'Tweet', TWEET_ALL: 'Δημοσίευση όλων ως Tweet', TWEET_INTERACTIONS: 'Αλληλεπιδράσεις με tweet', TWEET_YOUR_REPLY: 'Στείλτε την απάντησή σας', UNDO_RETWEET: 'Αναίρεση Retweet', VIEW: 'Προβολή', WHATS_HAPPENING: 'Τι συμβαίνει;', }, en: { ADD_ANOTHER_TWEET: 'Add another Tweet', ADD_MUTED_WORD: 'Add muted word', GROK_ACTIONS: 'Grok actions', HOME: 'Home', LIKES: 'Likes', MOST_RELEVANT: 'Most relevant', MUTE_THIS_CONVERSATION: 'Mute this conversation', POST_ALL: 'Post all', POST_UNAVAILABLE: 'This post is unavailable.', PROFILE_SUMMARY: 'Profile Summary', QUOTE: 'Quote', QUOTES: 'Quotes', QUOTE_TWEET: 'Quote Tweet', QUOTE_TWEETS: 'Quote Tweets', REPOST: 'Repost', REPOSTS: 'Reposts', RETWEET: 'Retweet', RETWEETED_BY: 'Retweeted by', RETWEETS: 'Retweets', SHARED: 'Shared', SHARED_TWEETS: 'Shared Tweets', SHOW: 'Show', SHOW_MORE_REPLIES: 'Show more replies', SORT_REPLIES_BY: 'Sort replies by', TURN_OFF_QUOTE_TWEETS: 'Turn off Quote Tweets', TURN_OFF_RETWEETS: 'Turn off Retweets', TURN_ON_RETWEETS: 'Turn on Retweets', TWEET: 'Tweet', TWEETS: 'Tweets', TWEET_ALL: 'Tweet all', TWEET_INTERACTIONS: 'Tweet interactions', TWEET_YOUR_REPLY: 'Tweet your reply', TWITTER: 'Twitter', UNDO_RETWEET: 'Undo Retweet', VIEW: 'View', WHATS_HAPPENING: "What's happening?", }, es: { ADD_ANOTHER_TWEET: 'Agregar otro Tweet', ADD_MUTED_WORD: 'Añadir palabra silenciada', GROK_ACTIONS: 'Acciones de Grok', HOME: 'Inicio', LIKES: 'Me gusta', MOST_RELEVANT: 'Más relevantes', MUTE_THIS_CONVERSATION: 'Silenciar esta conversación', POST_ALL: 'Postear todo', POST_UNAVAILABLE: 'Este post no está disponible.', PROFILE_SUMMARY: 'R###men del perfil', QUOTE: 'Cita', QUOTES: 'Citas', QUOTE_TWEET: 'Citar Tweet', QUOTE_TWEETS: 'Tweets citados', REPOST: 'Repostear', RETWEET: 'Retwittear', RETWEETED_BY: 'Retwitteado por', SHARED: 'Compartido', SHARED_TWEETS: 'Tweets compartidos', SHOW: 'Mostrar', SHOW_MORE_REPLIES: 'Mostrar más respuestas', SORT_REPLIES_BY: 'Ordenar respuestas por', TURN_OFF_QUOTE_TWEETS: 'Desactivar tweets citados', TURN_OFF_RETWEETS: 'Desactivar Retweets', TURN_ON_RETWEETS: 'Activar Retweets', TWEET: 'Twittear', TWEET_ALL: 'Twittear todo', TWEET_INTERACTIONS: 'Interacciones con Tweet', TWEET_YOUR_REPLY: 'Twittea tu respuesta', UNDO_RETWEET: 'Deshacer Retweet', VIEW: 'Ver', WHATS_HAPPENING: '¿Qué está pasando?', }, eu: { ADD_ANOTHER_TWEET: 'Gehitu beste txio bat', ADD_MUTED_WORD: 'Gehitu isilarazitako hitza', HOME: 'Hasiera', LIKES: 'Atsegiteak', MUTE_THIS_CONVERSATION: 'Isilarazi elkarrizketa hau', QUOTE: 'Aipamena', QUOTES: 'Aipamenak', QUOTE_TWEET: 'Txioa apaitu', QUOTE_TWEETS: 'Aipatu txioak', RETWEET: 'Bertxiotu', RETWEETED_BY: 'Bertxiotua:', RETWEETS: 'Bertxioak', SHARED: 'Partekatua', SHARED_TWEETS: 'Partekatutako', SHOW: 'Erakutsi', SHOW_MORE_REPLIES: 'Erakutsi erantzun gehiago', TURN_OFF_QUOTE_TWEETS: 'Desaktibatu aipatu txioak', TURN_OFF_RETWEETS: 'Desaktibatu birtxioak', TURN_ON_RETWEETS: 'Aktibatu birtxioak', TWEET: 'Txio', TWEETS: 'Txioak', TWEET_ALL: 'Txiotu guztiak', TWEET_INTERACTIONS: 'Txio elkarrekintzak', TWEET_YOUR_REPLY: 'Txiotu zure erantzuna', UNDO_RETWEET: 'Desegin birtxiokatzea', VIEW: 'Ikusi', WHATS_HAPPENING: 'Zer gertatzen ari da?', }, fa: { ADD_ANOTHER_TWEET: 'افزودن توییت دیگر', ADD_MUTED_WORD: 'افزودن واژه خموشسازی شده', GROK_ACTIONS: 'کنشهای Grok', HOME: 'خانه', LIKES: 'پسندها', MOST_RELEVANT: 'مرتبطترین', MUTE_THIS_CONVERSATION: 'خموشسازی این گفتگو', POST_ALL: 'پست کردن همه', POST_UNAVAILABLE: 'این پست دردسترس نیست.', PROFILE_SUMMARY: 'خلاصه نمایه', QUOTE: 'نقلقول', QUOTES: 'نقلقولها', QUOTE_TWEET: 'نقلتوییت', QUOTE_TWEETS: 'نقلتوییتها', REPOST: 'بازپست', REPOSTS: 'بازپست', RETWEET: 'بازتوییت', RETWEETED_BY: 'بازتوییت شد توسط', RETWEETS: 'بازتوییتها', SHARED: 'مشترک', SHARED_TWEETS: 'توییتهای مشترک', SHOW: 'نمایش', SHOW_MORE_REPLIES: 'نمایش پاسخهای بیشتر', SORT_REPLIES_BY: 'مرتبسازی پاسخها براساس', TURN_OFF_QUOTE_TWEETS: 'غیرفعالسازی نقلتوییتها', TURN_OFF_RETWEETS: 'غیرفعالسازی بازتوییتها', TURN_ON_RETWEETS: 'فعال سازی بازتوییتها', TWEET: 'توییت', TWEETS: 'توييتها', TWEET_ALL: 'توییت به همه', TWEET_INTERACTIONS: 'تعاملات توییت', TWEET_YOUR_REPLY: 'پاسختان را توییت کنید', TWITTER: 'توییتر', UNDO_RETWEET: 'لغو بازتوییت', VIEW: 'مشاهده', WHATS_HAPPENING: 'چه خبر؟', }, fi: { ADD_ANOTHER_TWEET: 'Lisää vielä twiitti', ADD_MUTED_WORD: 'Lisää hiljennetty sana', GROK_ACTIONS: 'Grok-toiminnat', HOME: 'Etusivu', LIKES: 'Tykkäykset', MOST_RELEVANT: 'Relevanteimmat', MUTE_THIS_CONVERSATION: 'Hiljennä tämä keskustelu', POST_ALL: 'Julkaise kaikki', POST_UNAVAILABLE: 'Tämä julkaisu ei ole saatavilla.', PROFILE_SUMMARY: 'Profiilin yhteenveto', QUOTE: 'Lainaa', QUOTES: 'Lainaukset', QUOTE_TWEET: 'Twiitin lainaus', QUOTE_TWEETS: 'Twiitin lainaukset', REPOST: 'Uudelleenjulkaise', REPOSTS: 'Uudelleenjulkaisut', RETWEET: 'Uudelleentwiittaa', RETWEETED_BY: 'Uudelleentwiitannut', RETWEETS: 'Uudelleentwiittaukset', SHARED: 'Jaettu', SHARED_TWEETS: 'Jaetut twiitit', SHOW: 'Näytä', SHOW_MORE_REPLIES: 'Näytä lisää vastauksia', SORT_REPLIES_BY: 'Vastausten lajittelutapa', TURN_OFF_QUOTE_TWEETS: 'Poista twiitin lainaukset käytöstä', TURN_OFF_RETWEETS: 'Poista uudelleentwiittaukset käytöstä', TURN_ON_RETWEETS: 'Ota uudelleentwiittaukset käyttöön', TWEET: 'Twiittaa', TWEETS: 'Twiitit', TWEET_ALL: 'Twiittaa kaikki', TWEET_INTERACTIONS: 'Twiitin vuorovaikutukset', TWEET_YOUR_REPLY: 'Twiittaa vastauksesi', UNDO_RETWEET: 'Kumoa uudelleentwiittaus', VIEW: 'Näytä', WHATS_HAPPENING: 'Missä mennään?', }, fil: { ADD_ANOTHER_TWEET: 'Magdagdag ng isa pang Tweet', ADD_MUTED_WORD: 'Idagdag ang naka-mute na salita', GROK_ACTIONS: 'Mga aksyon ni Grok', LIKES: 'Mga Gusto', MOST_RELEVANT: 'Pinakanauugnay', MUTE_THIS_CONVERSATION: 'I-mute ang usapang ito', POST_ALL: 'I-post lahat', POST_UNAVAILABLE: 'Hindi available ang post na Ito.', PROFILE_SUMMARY: 'Buod ng Profile', QUOTES: 'Mga Quote', QUOTE_TWEET: 'Quote na Tweet', QUOTE_TWEETS: 'Mga Quote na Tweet', REPOST: 'I-repost', REPOSTS: '(na) Repost', RETWEET: 'I-retweet', RETWEETED_BY: 'Ni-retweet ni', RETWEETS: 'Mga Retweet', SHARED: 'Ibinahagi', SHARED_TWEETS: 'Mga Ibinahaging Tweet', SHOW: 'Ipakita', SHOW_MORE_REPLIES: 'Magpakita pa ng mga sagot', SORT_REPLIES_BY: 'I-sort ang mga reply batay sa', TURN_OFF_QUOTE_TWEETS: 'I-off ang mga Quote na Tweet', TURN_OFF_RETWEETS: 'I-off ang Retweets', TURN_ON_RETWEETS: 'I-on ang Retweets', TWEET: 'Mag-tweet', TWEETS: 'Mga Tweet', TWEET_ALL: 'I-tweet lahat', TWEET_INTERACTIONS: 'Interaksyon sa Tweet', TWEET_YOUR_REPLY: 'I-Tweet ang reply mo', UNDO_RETWEET: 'Huwag nang I-retweet', VIEW: 'Tingnan', WHATS_HAPPENING: 'Ano ang nangyayari?', }, fr: { ADD_ANOTHER_TWEET: 'Ajouter un autre Tweet', ADD_MUTED_WORD: 'Ajouter un mot masqué', GROK_ACTIONS: 'Actions Grok', HOME: 'Accueil', LIKES: "J'aime", MOST_RELEVANT: 'Les plus pertinentes', MUTE_THIS_CONVERSATION: 'Masquer cette conversation', POST_ALL: 'Tout poster', POST_UNAVAILABLE: "Ce post n'est pas disponible.", PROFILE_SUMMARY: 'Résumé du profil', QUOTE: 'Citation', QUOTES: 'Citations', QUOTE_TWEET: 'Citer le Tweet', QUOTE_TWEETS: 'Tweets cités', RETWEET: 'Retweeter', RETWEETED_BY: 'Retweeté par', SHARED: 'Partagé', SHARED_TWEETS: 'Tweets partagés', SHOW: 'Afficher', SHOW_MORE_REPLIES: 'Voir plus de réponses', SORT_REPLIES_BY: 'Trier les réponses par', TURN_OFF_QUOTE_TWEETS: 'Désactiver les Tweets cités', TURN_OFF_RETWEETS: 'Désactiver les Retweets', TURN_ON_RETWEETS: 'Activer les Retweets', TWEET: 'Tweeter', TWEET_ALL: 'Tout tweeter', TWEET_INTERACTIONS: 'Interactions avec Tweet', TWEET_YOUR_REPLY: 'Tweetez votre réponse', UNDO_RETWEET: 'Annuler le Retweet', VIEW: 'Voir', WHATS_HAPPENING: 'Quoi de neuf !', }, ga: { ADD_ANOTHER_TWEET: 'Cuir Tweet eile leis', ADD_MUTED_WORD: 'Cuir focal balbhaithe leis', HOME: 'Baile', LIKES: 'Thaitin siad seo le', MUTE_THIS_CONVERSATION: 'Balbhaigh an comhrá seo', QUOTE: 'Sliocht', QUOTES: 'Sleachta', QUOTE_TWEET: 'Cuir Ráiteas Leis', QUOTE_TWEETS: 'Luaigh Tvuíteanna', RETWEET: 'Atweetáil', RETWEETED_BY: 'Atweetáilte ag', RETWEETS: 'Atweetanna', SHARED: 'Roinnte', SHARED_TWEETS: 'Tweetanna Roinnte', SHOW: 'Taispeáin', SHOW_MORE_REPLIES: 'Taispeáin tuilleadh freagraí', TURN_OFF_QUOTE_TWEETS: 'Cas as Luaigh Tvuíteanna', TURN_OFF_RETWEETS: 'Cas as Atweetanna', TURN_ON_RETWEETS: 'Cas Atweetanna air', TWEETS: 'Tweetanna', TWEET_ALL: 'Tweetáil gach rud', TWEET_INTERACTIONS: 'Idirghníomhaíochtaí le Tweet', TWEET_YOUR_REPLY: 'Tweetáil do fhreagra', UNDO_RETWEET: 'Cuir an Atweet ar ceal', VIEW: 'Breathnaigh', WHATS_HAPPENING: 'Cad atá ag tarlú?', }, gl: { ADD_ANOTHER_TWEET: 'Engadir outro chío', ADD_MUTED_WORD: 'Engadir palabra silenciada', HOME: 'Inicio', LIKES: 'Gústames', MUTE_THIS_CONVERSATION: 'Silenciar esta conversa', QUOTE: 'Cita', QUOTES: 'Citas', QUOTE_TWEET: 'Citar chío', QUOTE_TWEETS: 'Chíos citados', RETWEET: 'Rechouchiar', RETWEETED_BY: 'Rechouchiado por', RETWEETS: 'Rechouchíos', SHARED: 'Compartido', SHARED_TWEETS: 'Chíos compartidos', SHOW: 'Amosar', SHOW_MORE_REPLIES: 'Amosar máis respostas', TURN_OFF_QUOTE_TWEETS: 'Desactivar os chíos citados', TURN_OFF_RETWEETS: 'Desactivar os rechouchíos', TURN_ON_RETWEETS: 'Activar os rechouchíos', TWEET: 'Chío', TWEETS: 'Chíos', TWEET_ALL: 'Chiar todo', TWEET_INTERACTIONS: 'Interaccións chío', TWEET_YOUR_REPLY: 'Chío a túa responder', UNDO_RETWEET: 'Desfacer rechouchío', VIEW: 'Ver', WHATS_HAPPENING: 'Que está pasando?', }, gu: { ADD_ANOTHER_TWEET: 'અન્ય ટ્વીટ ઉમેરો', ADD_MUTED_WORD: 'જોડાણ અટકાવેલો શબ્દ ઉમેરો', GROK_ACTIONS: 'Grok પગલાં', HOME: 'હોમ', LIKES: 'લાઈક્સ', MOST_RELEVANT: 'સૌથી વધુ સુસંગત', MUTE_THIS_CONVERSATION: 'આ વાર્તાલાપનું જોડાણ અટકાવો', POST_ALL: 'બધા પોસ્ટ કરો', POST_UNAVAILABLE: 'આ પોસ્ટ અનુપલબ્ધ છે.', PROFILE_SUMMARY: 'પ્રોફાઇલ સારાંશ', QUOTE: 'અવતરણ', QUOTES: 'અવતરણો', QUOTE_TWEET: 'અવતરણની સાથે ટ્વીટ કરો', QUOTE_TWEETS: 'અવતરણની સાથે ટ્વીટ્સ', REPOST: 'રીપોસ્ટ કરો', REPOSTS: 'ફરીથી કરવામાં આવેલી પોસ્ટ', RETWEET: 'પુનટ્વીટ', RETWEETED_BY: 'આમની દ્વારા પુનટ્વીટ કરવામાં આવી', RETWEETS: 'પુનટ્વીટ્સ', SHARED: 'સાંજેડેલું', SHARED_TWEETS: 'શેર કરેલી ટ્વીટ્સ', SHOW: 'બતાવો', SHOW_MORE_REPLIES: 'વધુ પ્રત્યુતરો દર્શાવો', SORT_REPLIES_BY: 'દ્વારા પ્રત્યુત્તરોને સૉર્ટ કરો', TURN_OFF_QUOTE_TWEETS: 'અવતરણની સાથે ટ્વીટ્સ બંધ કરો', TURN_OFF_RETWEETS: 'પુનટ્વીટ્સ બંધ કરો', TURN_ON_RETWEETS: 'પુનટ્વીટ્સ ચાલુ કરો', TWEET: 'ટ્વીટ', TWEETS: 'ટ્વીટ્સ', TWEET_ALL: 'બધાને ટ્વીટ કરો', TWEET_INTERACTIONS: 'ટ્વીટ ક્રિયાપ્રતિક્રિયાઓ', TWEET_YOUR_REPLY: 'તમારા પ્રત્યુત્તરને ટ્વીટ કરો', UNDO_RETWEET: 'પુનટ્વીટને પૂર્વવત કરો', VIEW: 'જુઓ', WHATS_HAPPENING: 'શું થઈ રહ્યું છે?', }, he: { ADD_ANOTHER_TWEET: 'הוסף ציוץ נוסף', ADD_MUTED_WORD: 'הוסף מילה מושתקת', GROK_ACTIONS: 'פעולות של Grok', HOME: 'דף הבית', LIKES: 'הערות "אהבתי"', MOST_RELEVANT: 'הכי רלוונטי', MUTE_THIS_CONVERSATION: 'להשתיק את השיחה הזאת', POST_ALL: 'פרסום הכל', POST_UNAVAILABLE: 'פוסט זה אינו זמין.', PROFILE_SUMMARY: 'סיכום הפרופיל', QUOTE: 'ציטוט', QUOTES: 'ציטוטים', QUOTE_TWEET: 'ציטוט ציוץ', QUOTE_TWEETS: 'ציוצי ציטוט', REPOST: 'לפרסם מחדש', REPOSTS: 'פרסומים מחדש', RETWEET: 'צייץ מחדש', RETWEETED_BY: 'צויץ מחדש על־ידי', RETWEETS: 'ציוצים מחדש', SHARED: 'משותף', SHARED_TWEETS: 'ציוצים משותפים', SHOW: 'הצג', SHOW_MORE_REPLIES: 'הצג תשובות נוספות', SORT_REPLIES_BY: 'מיון תשובות לפי', TURN_OFF_QUOTE_TWEETS: 'כבה ציוצי ציטוט', TURN_OFF_RETWEETS: 'כבה ציוצים מחדש', TURN_ON_RETWEETS: 'הפעל ציוצים מחדש', TWEET: 'צייץ', TWEETS: 'ציוצים', TWEET_ALL: 'צייץ הכול', TWEET_INTERACTIONS: 'אינטראקציות צייץ', TWEET_YOUR_REPLY: 'צייץ התשובה', TWITTER: 'טוויטר', UNDO_RETWEET: 'ביטול ציוץ מחדש', VIEW: 'הצג', WHATS_HAPPENING: 'מה קורה?', }, hi: { ADD_ANOTHER_TWEET: 'एक और ट्वीट जोड़ें', ADD_MUTED_WORD: 'म्यूट किया गया शब्द जोड़ें', GROK_ACTIONS: 'Grok कार्रवाई', HOME: 'होम', LIKES: 'पसंद', MOST_RELEVANT: 'सर्वाधिक प्रासंगिक', MUTE_THIS_CONVERSATION: 'इस बातचीत को म्यूट करें', POST_ALL: 'सभी पोस्ट करें', POST_UNAVAILABLE: 'यह पोस्ट उपलब्ध नहीं है.', PROFILE_SUMMARY: 'प्रोफ़ाइल सारांश', QUOTE: 'कोट', QUOTES: 'कोट', QUOTE_TWEET: 'ट्वीट क्वोट करें', QUOTE_TWEETS: 'कोट ट्वीट्स', REPOST: 'रीपोस्ट', REPOSTS: 'रीपोस्ट्स', RETWEET: 'रीट्वीट करें', RETWEETED_BY: 'इनके द्वारा रीट्वीट किया गया', RETWEETS: 'रीट्वीट्स', SHARED: 'साझा किया हुआ', SHARED_TWEETS: 'साझा किए गए ट्वीट', SHOW: 'दिखाएं', SHOW_MORE_REPLIES: 'और अधिक जवाब दिखाएँ', SORT_REPLIES_BY: 'से जवाब सॉर्ट करें', TURN_OFF_QUOTE_TWEETS: 'कोट ट्वीट्स बंद करें', TURN_OFF_RETWEETS: 'रीट्वीट बंद करें', TURN_ON_RETWEETS: 'रीट्वीट चालू करें', TWEET: 'ट्वीट करें', TWEETS: 'ट्वीट', TWEET_ALL: 'सभी ट्वीट करें', TWEET_INTERACTIONS: 'ट्वीट इंटरैक्शन', TWEET_YOUR_REPLY: 'अपना जवाब ट्वीट करें', UNDO_RETWEET: 'रीट्वीट को पूर्ववत करें', VIEW: 'देखें', WHATS_HAPPENING: 'क्या हो रहा है?', }, hr: { ADD_ANOTHER_TWEET: 'Dodaj drugi Tweet', ADD_MUTED_WORD: 'Dodaj onemogućenu riječ', GROK_ACTIONS: 'Grokove radnje', HOME: 'Naslovnica', LIKES: 'Oznake „sviđa mi se”', MOST_RELEVANT: 'Najrelevantnije', MUTE_THIS_CONVERSATION: 'Isključi zvuk ovog razgovora', POST_ALL: 'Objavi sve', POST_UNAVAILABLE: 'Ta objava nije dostupna.', PROFILE_SUMMARY: 'Sažetak profila', QUOTE: 'Citat', QUOTES: 'Citati', QUOTE_TWEET: 'Citiraj Tweet', QUOTE_TWEETS: 'Citirani tweetovi', REPOST: 'Proslijedi objavu', REPOSTS: 'Proslijeđene objave', RETWEET: 'Proslijedi tweet', RETWEETED_BY: 'Korisnici koji su proslijedili Tweet', RETWEETS: 'Proslijeđeni tweetovi', SHARED: 'Podijeljeno', SHARED_TWEETS: 'Dijeljeni tweetovi', SHOW: 'Prikaži', SHOW_MORE_REPLIES: 'Prikaži još odgovora', SORT_REPLIES_BY: 'Sortiraj odgovore', TURN_OFF_QUOTE_TWEETS: 'Isključi citirane tweetove', TURN_OFF_RETWEETS: 'Isključi proslijeđene tweetove', TURN_ON_RETWEETS: 'Uključi proslijeđene tweetove', TWEETS: 'Tweetovi', TWEET_ALL: 'Tweetaj sve', TWEET_INTERACTIONS: 'Interakcije s Tweet', TWEET_YOUR_REPLY: 'Tweetajte odgovor', UNDO_RETWEET: 'Poništi prosljeđivanje tweeta', VIEW: 'Prikaz', WHATS_HAPPENING: 'Što se događa?', }, hu: { ADD_ANOTHER_TWEET: 'Másik Tweet hozzáadása', ADD_MUTED_WORD: 'Elnémított szó hozzáadása', GROK_ACTIONS: 'Grok-műveletek', HOME: 'Kezdőlap', LIKES: 'Kedvelések', MOST_RELEVANT: 'Legmegfelelőbb', MUTE_THIS_CONVERSATION: 'Beszélgetés némítása', POST_ALL: 'Az összes közzététele', POST_UNAVAILABLE: 'Ez a bejegyzés nem elérhető.', PROFILE_SUMMARY: 'Profil összegzése', QUOTE: 'Idézés', QUOTES: 'Idézések', QUOTE_TWEET: 'Tweet idézése', QUOTE_TWEETS: 'Tweet-idézések', REPOST: 'Újraposztolás', REPOSTS: 'Újraposztolások', RETWEETED_BY: 'Retweetelte', RETWEETS: 'Retweetek', SHARED: 'Megosztott', SHARED_TWEETS: 'Megosztott tweetek', SHOW: 'Megjelenítés', SHOW_MORE_REPLIES: 'Több válasz megjelenítése', SORT_REPLIES_BY: 'Válaszok rendezése a következő szerint', TURN_OFF_QUOTE_TWEETS: 'Tweet-idézések kikapcsolása', TURN_OFF_RETWEETS: 'Retweetek kikapcsolása', TURN_ON_RETWEETS: 'Retweetek bekapcsolása', TWEET: 'Tweetelj', TWEETS: 'Tweetek', TWEET_ALL: 'Tweet küldése mindenkinek', TWEET_INTERACTIONS: 'Tweet interakciók', TWEET_YOUR_REPLY: 'Tweeteld válaszodat', UNDO_RETWEET: 'Retweet visszavonása', VIEW: 'Megtekintés', WHATS_HAPPENING: 'Mi történik éppen most?', }, id: { ADD_ANOTHER_TWEET: 'Tambahkan Tweet lain', ADD_MUTED_WORD: 'Tambahkan kata kunci yang dibisukan', GROK_ACTIONS: 'Tindakan Grok', HOME: 'Beranda', LIKES: 'Suka', MOST_RELEVANT: 'Paling relevan', MUTE_THIS_CONVERSATION: 'Bisukan percakapan ini', POST_ALL: 'Posting semua', POST_UNAVAILABLE: 'Postingan ini tidak tersedia.', PROFILE_SUMMARY: 'Ringkasan Profil', QUOTE: 'Kutipan', QUOTES: 'Kutipan', QUOTE_TWEET: 'Kutip Tweet', QUOTE_TWEETS: 'Tweet Kutipan', REPOST: 'Posting ulang', REPOSTS: 'Posting ulang', RETWEETED_BY: 'Di-retweet oleh', RETWEETS: 'Retweet', SHARED: 'Dibagikan', SHARED_TWEETS: 'Tweet yang Dibagikan', SHOW: 'Tampilkan', SHOW_MORE_REPLIES: 'Tampilkan balasan lainnya', SORT_REPLIES_BY: 'Urutkan balasan berdasarkan', TURN_OFF_QUOTE_TWEETS: 'Matikan Tweet Kutipan', TURN_OFF_RETWEETS: 'Matikan Retweet', TURN_ON_RETWEETS: 'Nyalakan Retweet', TWEETS: 'Tweet', TWEET_ALL: 'Tweet semua', TWEET_INTERACTIONS: 'Interaksi Tweet', TWEET_YOUR_REPLY: 'Tweet balasan Anda', UNDO_RETWEET: 'Batalkan Retweet', VIEW: 'Lihat', WHATS_HAPPENING: 'Apa yang sedang hangat dibicarakan?', }, it: { ADD_ANOTHER_TWEET: 'Aggiungi altro Tweet', ADD_MUTED_WORD: 'Aggiungi parola o frase silenziata', GROK_ACTIONS: 'Azioni di Grok', LIKES: 'Mi piace', MOST_RELEVANT: 'Più pertinenti', MUTE_THIS_CONVERSATION: 'Silenzia questa conversazione', POST_ALL: 'Posta tutto', POST_UNAVAILABLE: 'Questo post non è disponibile.', PROFILE_SUMMARY: 'Riepilogo del profilo', QUOTE: 'Citazione', QUOTES: 'Citazioni', QUOTE_TWEET: 'Cita Tweet', QUOTE_TWEETS: 'Tweet di citazione', REPOSTS: 'Repost', RETWEET: 'Ritwitta', RETWEETED_BY: 'Ritwittato da', RETWEETS: 'Retweet', SHARED: 'Condiviso', SHARED_TWEETS: 'Tweet condivisi', SHOW: 'Mostra', SHOW_MORE_REPLIES: 'Mostra altre risposte', SORT_REPLIES_BY: 'Ordina risposte per', TURN_OFF_QUOTE_TWEETS: 'Disattiva i Tweet di citazione', TURN_OFF_RETWEETS: 'Disattiva Retweet', TURN_ON_RETWEETS: 'Attiva Retweet', TWEET: 'Twitta', TWEETS: 'Tweet', TWEET_ALL: 'Twitta tutto', TWEET_INTERACTIONS: 'Interazioni con Tweet', TWEET_YOUR_REPLY: 'Twitta la tua risposta', UNDO_RETWEET: 'Annulla Retweet', VIEW: 'Visualizza', WHATS_HAPPENING: "Che c'è di nuovo?", }, ja: { ADD_ANOTHER_TWEET: '別のツイートを追加する', ADD_MUTED_WORD: 'ミュートするキーワードを追加', GROK_ACTIONS: 'Grokのアクション', HOME: 'ホーム', LIKES: 'いいね', MOST_RELEVANT: '関連性が高い', MUTE_THIS_CONVERSATION: 'この会話をミュート', POST_ALL: 'すべてポスト', POST_UNAVAILABLE: 'このポストは表示できません。', PROFILE_SUMMARY: 'プロフィールの要約', QUOTE: '引用', QUOTES: '引用', QUOTE_TWEET: '引用ツイート', QUOTE_TWEETS: '引用ツイート', REPOST: 'リポスト', REPOSTS: 'リポスト', RETWEET: 'リツイート', RETWEETED_BY: 'リツイートしたユーザー', RETWEETS: 'リツイート', SHARED: '共有', SHARED_TWEETS: '共有ツイート', SHOW: '表示', SHOW_MORE_REPLIES: '返信をさらに表示', SORT_REPLIES_BY: '返信の並べ替え基準', TURN_OFF_QUOTE_TWEETS: '引用ツイートをオフにする', TURN_OFF_RETWEETS: 'リツイートをオフにする', TURN_ON_RETWEETS: 'リツイートをオンにする', TWEET: 'ツイートする', TWEETS: 'ツイート', TWEET_ALL: 'すべてツイート', TWEET_INTERACTIONS: 'ツイートの相互作用', TWEET_YOUR_REPLY: '返信をツイート', UNDO_RETWEET: 'リツイートを取り消す', VIEW: '表示する', WHATS_HAPPENING: 'いまどうしてる?', }, kn: { ADD_ANOTHER_TWEET: 'ಮತ್ತೊಂದು ಟ್ವೀಟ್ ಸೇರಿಸಿ', ADD_MUTED_WORD: 'ಸದ್ದಡಗಿಸಿದ ಪದವನ್ನು ಸೇರಿಸಿ', GROK_ACTIONS: 'Grok ಕ್ರಮಗಳು', HOME: 'ಹೋಮ್', LIKES: 'ಇಷ್ಟಗಳು', MOST_RELEVANT: 'ಅತ್ಯಂತ ಸಂಬಂಧಿತ', MUTE_THIS_CONVERSATION: 'ಈ ಸಂವಾದವನ್ನು ಸದ್ದಡಗಿಸಿ', POST_ALL: 'ಎಲ್ಲವನ್ನೂ ಪೋಸ್ಟ್ ಮಾಡಿ', POST_UNAVAILABLE: 'ಈ ಪೋಸ್ಟ್ ಲಭ್ಯವಿಲ್ಲ.', PROFILE_SUMMARY: 'ಪ್ರೊಫೈಲ್ ಸಾರಾಂಶ', QUOTE: 'ಕೋಟ್', QUOTES: 'ಉಲ್ಲೇಖಗಳು', QUOTE_TWEET: 'ಟ್ವೀಟ್ ಕೋಟ್ ಮಾಡಿ', QUOTE_TWEETS: 'ಕೋಟ್ ಟ್ವೀಟ್ಗಳು', REPOST: 'ಮರುಪೋಸ್ಟ್ ಮಾಡಿ', REPOSTS: 'ಮರುಪೋಸ್ಟ್ಗಳು', RETWEET: 'ಮರುಟ್ವೀಟಿಸಿ', RETWEETED_BY: 'ಮರುಟ್ವೀಟಿಸಿದವರು', RETWEETS: 'ಮರುಟ್ವೀಟ್ಗಳು', SHARED: 'ಹಂಚಲಾಗಿದೆ', SHARED_TWEETS: 'ಹಂಚಿದ ಟ್ವೀಟ್ಗಳು', SHOW: 'ತೋರಿಸಿ', SHOW_MORE_REPLIES: 'ಇನ್ನಷ್ಟು ಪ್ರತಿಕ್ರಿಯೆಗಳನ್ನು ತೋರಿಸಿ', SORT_REPLIES_BY: 'ಇದರ ಮೂಲಕ ಪ್ರತಿಕ್ರಿಯೆಗಳನ್ನು ಆಯೋಜಿಸಿ', TURN_OFF_QUOTE_TWEETS: 'ಕೋಟ್ ಟ್ವೀಟ್ಗಳನ್ನು ಆಫ್ ಮಾಡಿ', TURN_OFF_RETWEETS: 'ಮರುಟ್ವೀಟ್ಗಳನ್ನು ಆಫ್ ಮಾಡಿ', TURN_ON_RETWEETS: 'ಮರುಟ್ವೀಟ್ಗಳನ್ನು ಆನ್ ಮಾಡಿ', TWEET: 'ಟ್ವೀಟ್', TWEETS: 'ಟ್ವೀಟ್ಗಳು', TWEET_ALL: 'ಎಲ್ಲಾ ಟ್ವೀಟ್ ಮಾಡಿ', TWEET_INTERACTIONS: 'ಟ್ವೀಟ್ ಸಂವಾದಗಳು', TWEET_YOUR_REPLY: 'ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆಯನ್ನು ಟ್ವೀಟ್ ಮಾಡಿ', UNDO_RETWEET: 'ಮರುಟ್ವೀಟಿಸುವುದನ್ನು ರದ್ದುಮಾಡಿ', VIEW: 'ವೀಕ್ಷಿಸಿ', WHATS_HAPPENING: 'ಏನು ನಡೆಯುತ್ತಿದೆ?', }, ko: { ADD_ANOTHER_TWEET: '다른 트윗 추가하기', ADD_MUTED_WORD: '뮤트할 단어 추가하기', GROK_ACTIONS: 'Grok 작업', HOME: '홈', LIKES: '마음에 들어요', MOST_RELEVANT: '관련도 순서', MUTE_THIS_CONVERSATION: '이 대화 뮤트하기', POST_ALL: '모두 게시하기', POST_UNAVAILABLE: '이 게시물을 볼 수 없습니다.', PROFILE_SUMMARY: '프로필 요약', QUOTE: '인용', QUOTES: '인용', QUOTE_TWEET: '트윗 인용하기', QUOTE_TWEETS: '트윗 인용하기', REPOST: '재게시', REPOSTS: '재게시', RETWEET: '리트윗', RETWEETED_BY: '리트윗함', RETWEETS: '리트윗', SHARED: '공유된', SHARED_TWEETS: '공유 트윗', SHOW: '표시', SHOW_MORE_REPLIES: '더 많은 답글 보기', SORT_REPLIES_BY: '답글 정렬하기', TURN_OFF_QUOTE_TWEETS: '인용 트윗 끄기', TURN_OFF_RETWEETS: '리트윗 끄기', TURN_ON_RETWEETS: '리트윗 켜기', TWEET: '트윗', TWEETS: '트윗', TWEET_ALL: '모두 트윗하기', TWEET_INTERACTIONS: '트윗 상호작용', TWEET_YOUR_REPLY: '답글을 트윗하세요', TWITTER: '트위터', UNDO_RETWEET: '리트윗 취소', VIEW: '보기', WHATS_HAPPENING: '무슨 일이 일어나고 있나요?', }, mr: { ADD_ANOTHER_TWEET: 'दुसरे ट्विट सामील करा', ADD_MUTED_WORD: 'म्यूट केलेले शब्द सामील करा', GROK_ACTIONS: 'Grok कृती', HOME: 'होम', LIKES: 'पसंती', MOST_RELEVANT: 'सर्वात महत्वाचे', MUTE_THIS_CONVERSATION: 'ही चर्चा म्यूट करा', POST_ALL: 'सर्व पोस्ट करा', POST_UNAVAILABLE: 'हे पोस्ट अनुपलब्ध आहे.', PROFILE_SUMMARY: 'प्रोफाइल सारांश', QUOTE: 'भाष्य', QUOTES: 'भाष्य', QUOTE_TWEET: 'ट्विट वर भाष्य करा', QUOTE_TWEETS: 'भाष्य ट्विट्स', REPOST: 'पुन्हा पोस्ट करा', REPOSTS: 'रिपोस्ट', RETWEET: 'पुन्हा ट्विट', RETWEETED_BY: 'यांनी पुन्हा ट्विट केले', RETWEETS: 'पुनर्ट्विट्स', SHARED: 'सामायिक', SHARED_TWEETS: 'सामायिक ट्विट', SHOW: 'दाखवा', SHOW_MORE_REPLIES: 'अधिक प्रत्युत्तरे दाखवा', SORT_REPLIES_BY: 'द्वारे प्रत्युत्तरांची क्रमवारी करा', TURN_OFF_QUOTE_TWEETS: 'भाष्य ट्विट्स बंद करा', TURN_OFF_RETWEETS: 'पुनर्ट्विट्स बंद करा', TURN_ON_RETWEETS: 'पुनर्ट्विट्स चालू करा', TWEET: 'ट्विट', TWEETS: 'ट्विट्स', TWEET_ALL: 'सर्व ट्विट करा', TWEET_INTERACTIONS: 'ट्वीट इंटरऍक्शन्स', TWEET_YOUR_REPLY: 'आपले प्रत्युत्तर ट्विट करा', UNDO_RETWEET: 'पुनर्ट्विट पूर्ववत करा', VIEW: 'पहा', WHATS_HAPPENING: 'ताज्या घडामोडी?', }, ms: { ADD_ANOTHER_TWEET: 'Tambahkan Tweet lain', ADD_MUTED_WORD: 'Tambahkan perkataan yang disenyapkan', GROK_ACTIONS: 'Tindakan Grok', HOME: 'Laman Utama', LIKES: 'Suka', MOST_RELEVANT: 'Paling berkaitan', MUTE_THIS_CONVERSATION: 'Senyapkan perbualan ini', POST_ALL: 'Siarkan semua', POST_UNAVAILABLE: 'Siaran ini tidak tersedia.', PROFILE_SUMMARY: 'Ringkasan Profil', QUOTE: 'Petikan', QUOTES: 'Petikan', QUOTE_TWEET: 'Petik Tweet', QUOTE_TWEETS: 'Tweet Petikan', REPOST: 'Siaran semula', REPOSTS: 'Siaran semula', RETWEET: 'Tweet semula', RETWEETED_BY: 'Ditweet semula oleh', RETWEETS: 'Tweet semula', SHARED: 'Dikongsi', SHARED_TWEETS: 'Tweet Berkongsi', SHOW: 'Tunjukkan', SHOW_MORE_REPLIES: 'Tunjukkan lagi balasan', SORT_REPLIES_BY: 'Isih balasan mengikut', TURN_OFF_QUOTE_TWEETS: 'Matikan Tweet Petikan', TURN_OFF_RETWEETS: 'Matikan Tweet semula', TURN_ON_RETWEETS: 'Hidupkan Tweet semula', TWEETS: 'Tweet', TWEET_ALL: 'Tweet semua', TWEET_INTERACTIONS: 'Interaksi Tweet', TWEET_YOUR_REPLY: 'Tweet balasan anda', UNDO_RETWEET: 'Buat asal Tweet semula', VIEW: 'Lihat', WHATS_HAPPENING: 'Apakah yang sedang berlaku?', }, nb: { ADD_ANOTHER_TWEET: 'Legg til en annen Tweet', ADD_MUTED_WORD: 'Skjul nytt ord', GROK_ACTIONS: 'Grok-handlinger', HOME: 'Hjem', LIKES: 'Liker', MOST_RELEVANT: 'Mest relevante', MUTE_THIS_CONVERSATION: 'Skjul denne samtalen', POST_ALL: 'Publiser alle', POST_UNAVAILABLE: 'Dette innlegget er utilgjengelig.', PROFILE_SUMMARY: 'Profilsammendrag', QUOTE: 'Sitat', QUOTES: 'Sitater', QUOTE_TWEET: 'Sitat-Tweet', QUOTE_TWEETS: 'Sitat-Tweets', REPOST: 'Republiser', REPOSTS: 'Republiseringer', RETWEETED_BY: 'Retweetet av', SHARED: 'Delt', SHARED_TWEETS: 'Delte tweets', SHOW: 'Vis', SHOW_MORE_REPLIES: 'Vis flere svar', SORT_REPLIES_BY: 'Sorter svar etter', TURN_OFF_QUOTE_TWEETS: 'Slå av sitat-tweets', TURN_OFF_RETWEETS: 'Slå av Retweets', TURN_ON_RETWEETS: 'Slå på Retweets', TWEET_ALL: 'Tweet alle', TWEET_INTERACTIONS: 'Tweet-interaksjoner', TWEET_YOUR_REPLY: 'Tweet svaret ditt', UNDO_RETWEET: 'Angre Retweet', VIEW: 'Vis', WHATS_HAPPENING: 'Hva skjer?', }, nl: { ADD_ANOTHER_TWEET: 'Nog een Tweet toevoegen', ADD_MUTED_WORD: 'Genegeerd woord toevoegen', GROK_ACTIONS: 'Grok-acties', HOME: 'Startpagina', LIKES: 'Vind-ik-leuks', MOST_RELEVANT: 'Meest relevant', MUTE_THIS_CONVERSATION: 'Dit gesprek negeren', POST_ALL: 'Alles plaatsen', POST_UNAVAILABLE: 'Deze post is niet beschikbaar.', PROFILE_SUMMARY: 'Profieloverzicht', QUOTE: 'Geciteerd', QUOTES: 'Geciteerd', QUOTE_TWEET: 'Citeer Tweet', QUOTE_TWEETS: 'Geciteerde Tweets', RETWEET: 'Retweeten', RETWEETED_BY: 'Geretweet door', SHARED: 'Gedeeld', SHARED_TWEETS: 'Gedeelde Tweets', SHOW: 'Weergeven', SHOW_MORE_REPLIES: 'Meer antwoorden tonen', SORT_REPLIES_BY: 'Antwoorden sorteren op', TURN_OFF_QUOTE_TWEETS: 'Geciteerde Tweets uitschakelen', TURN_OFF_RETWEETS: 'Retweets uitschakelen', TURN_ON_RETWEETS: 'Retweets inschakelen', TWEET: 'Tweeten', TWEET_ALL: 'Alles tweeten', TWEET_INTERACTIONS: 'Tweet-interacties', TWEET_YOUR_REPLY: 'Tweet je antwoord', UNDO_RETWEET: 'Retweet ongedaan maken', VIEW: 'Bekijken', WHATS_HAPPENING: 'Wat gebeurt er?', }, pl: { ADD_ANOTHER_TWEET: 'Dodaj kolejnego Tweeta', ADD_MUTED_WORD: 'Dodaj wyciszone słowo', GROK_ACTIONS: 'Akcje Groka', HOME: 'Główna', LIKES: 'Polubienia', MOST_RELEVANT: 'Najtrafniejsze', MUTE_THIS_CONVERSATION: 'Wycisz tę rozmowę', POST_ALL: 'Opublikuj wszystko', POST_UNAVAILABLE: 'Ten wpis jest niedostępny.', PROFILE_SUMMARY: 'Podsumowanie profilu', QUOTE: 'Cytuj', QUOTES: 'Cytaty', QUOTE_TWEET: 'Cytuj Tweeta', QUOTE_TWEETS: 'Cytaty z Tweeta', REPOST: 'Podaj dalej wpis', REPOSTS: 'Wpisy podane dalej', RETWEET: 'Podaj dalej', RETWEETED_BY: 'Podane dalej przez', RETWEETS: 'Tweety podane dalej', SHARED: 'Udostępniony', SHARED_TWEETS: 'Udostępnione Tweety', SHOW: 'Pokaż', SHOW_MORE_REPLIES: 'Pokaż więcej odpowiedzi', SORT_REPLIES_BY: 'Sortuj odpowiedzi wg', TURN_OFF_QUOTE_TWEETS: 'Wyłącz tweety z cytatem', TURN_OFF_RETWEETS: 'Wyłącz Tweety podane dalej', TURN_ON_RETWEETS: 'Włącz Tweety podane dalej', TWEETS: 'Tweety', TWEET_ALL: 'Tweetnij wszystko', TWEET_INTERACTIONS: 'Interakcje na Tweeta', TWEET_YOUR_REPLY: 'Tweeta swoją odpowiedź', UNDO_RETWEET: 'Cofnij podanie dalej', VIEW: 'Wyświetl', WHATS_HAPPENING: 'Co się dzieje?', }, pt: { ADD_ANOTHER_TWEET: 'Adicionar outro Tweet', ADD_MUTED_WORD: 'Adicionar palavra silenciada', GROK_ACTIONS: 'Ações do Grok', HOME: 'Página Inicial', LIKES: 'Curtidas', MOST_RELEVANT: 'Mais relevante', MUTE_THIS_CONVERSATION: 'Silenciar esta conversa', POST_ALL: 'Postar tudo', POST_UNAVAILABLE: 'Este post está indisponível.', PROFILE_SUMMARY: 'R###mo do perfil', QUOTE: 'Comentar', QUOTES: 'Comentários', QUOTE_TWEET: 'Comentar o Tweet', QUOTE_TWEETS: 'Tweets com comentário', REPOST: 'Repostar', RETWEET: 'Retweetar', RETWEETED_BY: 'Retweetado por', SHARED: 'Compartilhado', SHARED_TWEETS: 'Tweets Compartilhados', SHOW: 'Mostrar', SHOW_MORE_REPLIES: 'Mostrar mais respostas', SORT_REPLIES_BY: 'Ordenar respostas por', TURN_OFF_QUOTE_TWEETS: 'Desativar Tweets com comentário', TURN_OFF_RETWEETS: 'Desativar Retweets', TURN_ON_RETWEETS: 'Ativar Retweets', TWEET: 'Tweetar', TWEET_ALL: 'Tweetar tudo', TWEET_INTERACTIONS: 'Interações com Tweet', TWEET_YOUR_REPLY: 'Tweetar sua resposta', UNDO_RETWEET: 'Desfazer Retweet', VIEW: 'Ver', WHATS_HAPPENING: 'O que está acontecendo?', }, ro: { ADD_ANOTHER_TWEET: 'Adaugă alt Tweet', ADD_MUTED_WORD: 'Adaugă cuvântul ignorat', GROK_ACTIONS: 'Acțiuni Grok', HOME: 'Pagina principală', LIKES: 'Aprecieri', MOST_RELEVANT: 'Cele mai relevante', MUTE_THIS_CONVERSATION: 'Ignoră această conversație', POST_ALL: 'Postează tot', POST_UNAVAILABLE: 'Această postare este indisponibilă.', PROFILE_SUMMARY: 'Sumarul profilului', QUOTE: 'Citat', QUOTES: 'Citate', QUOTE_TWEET: 'Citează Tweetul', QUOTE_TWEETS: 'Tweeturi cu citat', REPOST: 'Repostează', REPOSTS: 'Repostări', RETWEET: 'Redistribuie', RETWEETED_BY: 'Redistribuit de către', RETWEETS: 'Retweeturi', SHARED: 'Partajat', SHARED_TWEETS: 'Tweeturi partajate', SHOW: 'Afișează', SHOW_MORE_REPLIES: 'Afișează mai multe răspunsuri', SORT_REPLIES_BY: 'Sortare răspunsuri după', TURN_OFF_QUOTE_TWEETS: 'Dezactivează tweeturile cu citat', TURN_OFF_RETWEETS: 'Dezactivează Retweeturile', TURN_ON_RETWEETS: 'Activează Retweeturile', TWEETS: 'Tweeturi', TWEET_ALL: 'Dă Tweeturi cu tot', TWEET_INTERACTIONS: 'Interacțiuni cu Tweetul', TWEET_YOUR_REPLY: 'Dă Tweet cu răspunsul', UNDO_RETWEET: 'Anulează Retweetul', VIEW: 'Vezi', WHATS_HAPPENING: 'Ce se întâmplă?', }, ru: { ADD_ANOTHER_TWEET: 'Добавить еще один твит', ADD_MUTED_WORD: 'Добавить игнорируемое слово', GROK_ACTIONS: 'Действия Grok', HOME: 'Главная', LIKES: 'Нравится', MOST_RELEVANT: 'Наиболее актуальные', MUTE_THIS_CONVERSATION: 'Игнорировать эту переписку', POST_ALL: 'Опубликовать все', POST_UNAVAILABLE: 'Этот пост недоступен.', PROFILE_SUMMARY: 'Сводка профиля', QUOTE: 'Цитата', QUOTES: 'Цитаты', QUOTE_TWEET: 'Цитировать', QUOTE_TWEETS: 'Твиты с цитатами', REPOST: 'Сделать репост', REPOSTS: 'Репосты', RETWEET: 'Ретвитнуть', RETWEETED_BY: 'Ретвитнул(а)', RETWEETS: 'Ретвиты', SHARED: 'Общий', SHARED_TWEETS: 'Общие твиты', SHOW: 'Показать', SHOW_MORE_REPLIES: 'Показать ещё ответы', SORT_REPLIES_BY: 'Упорядочить ответы по', TURN_OFF_QUOTE_TWEETS: 'Отключить твиты с цитатами', TURN_OFF_RETWEETS: 'Отключить ретвиты', TURN_ON_RETWEETS: 'Включить ретвиты', TWEET: 'Твитнуть', TWEETS: 'Твиты', TWEET_ALL: 'Твитнуть все', TWEET_INTERACTIONS: 'Взаимодействие в Твитнуть', TWEET_YOUR_REPLY: 'Твитните свой ответ', TWITTER: 'Твиттер', UNDO_RETWEET: 'Отменить ретвит', VIEW: 'Посмотреть', WHATS_HAPPENING: 'Что происходит?', }, sk: { ADD_ANOTHER_TWEET: 'Pridať ďalší Tweet', ADD_MUTED_WORD: 'Pridať stíšené slovo', GROK_ACTIONS: 'Akcie Groka', HOME: 'Domov', LIKES: 'Páči sa', MOST_RELEVANT: 'Najrelevantnejšie', MUTE_THIS_CONVERSATION: 'Stíšiť túto konverzáciu', POST_ALL: 'Uverejniť všetko', POST_UNAVAILABLE: 'Tento príspevok je nedostupný.', PROFILE_SUMMARY: 'Súhrn profilu', QUOTE: 'Citát', QUOTES: 'Citáty', QUOTE_TWEET: 'Tweet s citátom', QUOTE_TWEETS: 'Tweety s citátom', REPOST: 'Opätovné uverejnenie', REPOSTS: 'Opätovné uverejnenia', RETWEET: 'Retweetnuť', RETWEETED_BY: 'Retweetnuté používateľom', RETWEETS: 'Retweety', SHARED: 'Zdieľaný', SHARED_TWEETS: 'Zdieľané Tweety', SHOW: 'Zobraziť', SHOW_MORE_REPLIES: 'Zobraziť viac odpovedí', SORT_REPLIES_BY: 'Zoradiť odpovede podľa', TURN_OFF_QUOTE_TWEETS: 'Vypnúť tweety s citátom', TURN_OFF_RETWEETS: 'Vypnúť retweety', TURN_ON_RETWEETS: 'Zapnúť retweety', TWEET: 'Tweetnuť', TWEETS: 'Tweety', TWEET_ALL: 'Tweetnuť všetko', TWEET_INTERACTIONS: 'Interakcie s Tweet', TWEET_YOUR_REPLY: 'Tweetnite odpoveď', UNDO_RETWEET: 'Zrušiť retweet', VIEW: 'Zobraziť', WHATS_HAPPENING: 'Čo sa deje?', }, sr: { ADD_ANOTHER_TWEET: 'Додај још један твит', ADD_MUTED_WORD: 'Додај игнорисану реч', GROK_ACTIONS: 'Grok радње', HOME: 'Почетна', LIKES: 'Свиђања', MOST_RELEVANT: 'Најважније', MUTE_THIS_CONVERSATION: 'Игнориши овај разговор', POST_ALL: 'Објави све', POST_UNAVAILABLE: 'Ова објава није доступна.', PROFILE_SUMMARY: 'Резиме профила', QUOTE: 'Цитат', QUOTES: 'Цитати', QUOTE_TWEET: 'твит са цитатом', QUOTE_TWEETS: 'твит(ов)а са цитатом', REPOST: 'Поново објави', REPOSTS: 'Понвне објаве', RETWEET: 'Ретвитуј', RETWEETED_BY: 'Ретвитовано од стране', RETWEETS: 'Ретвитови', SHARED: 'Подељено', SHARED_TWEETS: 'Дељени твитови', SHOW: 'Прикажи', SHOW_MORE_REPLIES: 'Прикажи још одговора', SORT_REPLIES_BY: 'Сортирај одговоре по', TURN_OFF_QUOTE_TWEETS: 'Искључи твит(ов)е са цитатом', TURN_OFF_RETWEETS: 'Искључи ретвитове', TURN_ON_RETWEETS: 'Укључи ретвитове', TWEET: 'Твитуј', TWEETS: 'Твитови', TWEET_ALL: 'Твитуј све', TWEET_INTERACTIONS: 'Интеракције са Твитуј', TWEET_YOUR_REPLY: 'Твитуј свој одговор', TWITTER: 'Твитер', UNDO_RETWEET: 'Опозови ретвит', VIEW: 'Погледај', WHATS_HAPPENING: 'Шта се дешава?', }, sv: { ADD_ANOTHER_TWEET: 'Lägg till en Tweet till', ADD_MUTED_WORD: 'Lägg till ignorerat ord', GROK_ACTIONS: 'Grok-åtgärder', HOME: 'Hem', LIKES: 'Gilla-markeringar', MOST_RELEVANT: 'Mest relevant', MUTE_THIS_CONVERSATION: 'Ignorera den här konversationen', POST_ALL: 'Lägg upp allt', POST_UNAVAILABLE: 'Detta inlägg är inte tillgängligt.', PROFILE_SUMMARY: 'Profilöversikt', QUOTE: 'Citat', QUOTES: 'Citat', QUOTE_TWEET: 'Citera Tweet', QUOTE_TWEETS: 'Citat-tweets', REPOST: 'Återpublicera', REPOSTS: 'Återpubliceringar', RETWEET: 'Retweeta', RETWEETED_BY: 'Retweetad av', SHARED: 'Delad', SHARED_TWEETS: 'Delade tweetsen', SHOW: 'Visa', SHOW_MORE_REPLIES: 'Visa fler svar', SORT_REPLIES_BY: 'Sortera svar på', TURN_OFF_QUOTE_TWEETS: 'Stäng av citat-tweets', TURN_OFF_RETWEETS: 'Stäng av Retweets', TURN_ON_RETWEETS: 'Slå på Retweets', TWEET: 'Tweeta', TWEET_ALL: 'Tweeta allt', TWEET_INTERACTIONS: 'Interaktioner med Tweet', TWEET_YOUR_REPLY: 'Tweeta ditt svar', UNDO_RETWEET: 'Ångra retweeten', VIEW: 'Visa', WHATS_HAPPENING: 'Vad är det som händer?', }, ta: { ADD_ANOTHER_TWEET: 'வேறொரு கீச்சைச் சேர்', ADD_MUTED_WORD: 'செயல்மறைத்த வார்த்தையைச் சேர்', GROK_ACTIONS: 'Grok செயல்கள்', HOME: 'முகப்பு', LIKES: 'விருப்பங்கள்', MOST_RELEVANT: 'மிகவும் தொடர்புடையவை', MUTE_THIS_CONVERSATION: 'இந்த உரையாடலை செயல்மறை', POST_ALL: 'எல்லாம் இடுகையிடு', POST_UNAVAILABLE: 'இந்த இடுகை கிடைக்கவில்லை.', PROFILE_SUMMARY: 'சுயவிவரச் சுருக்கம்', QUOTE: 'மேற்கோள்', QUOTES: 'மேற்கோள்கள்', QUOTE_TWEET: 'ட்விட்டை மேற்கோள் காட்டு', QUOTE_TWEETS: 'மேற்கோள் கீச்சுகள்', REPOST: 'மறுஇடுகை', REPOSTS: 'மறுஇடுகைகள்', RETWEET: 'மறுட்விட் செய்', RETWEETED_BY: 'இவரால் மறுட்விட் செய்யப்பட்டது', RETWEETS: 'மறுகீச்சுகள்', SHARED: 'பகிரப்பட்டது', SHARED_TWEETS: 'பகிரப்பட்ட ட்வீட்டுகள்', SHOW: 'காண்பி', SHOW_MORE_REPLIES: 'மேலும் பதில்களைக் காண்பி', SORT_REPLIES_BY: 'இதன்படி பதில்களை வகைப்படுத்து', TURN_OFF_QUOTE_TWEETS: 'மேற்கோள் கீச்சுகளை அணை', TURN_OFF_RETWEETS: 'மறுகீச்சுகளை அணை', TURN_ON_RETWEETS: 'மறுகீச்சுகளை இயக்கு', TWEET: 'ட்விட் செய்', TWEETS: 'கீச்சுகள்', TWEET_ALL: 'அனைத்தையும் ட்விட் செய்', TWEET_INTERACTIONS: 'ட்விட் செய் ஊடாடல்களைக்', TWEET_YOUR_REPLY: 'உங்கள் பதிலை ட்விட் செய்யவும்', UNDO_RETWEET: 'மறுகீச்சை செயல்தவிர்', VIEW: 'காண்பி', WHATS_HAPPENING: 'என்ன நிகழ்கிறது?', }, th: { ADD_ANOTHER_TWEET: 'เพิ่มอีกทวีต', ADD_MUTED_WORD: 'เพิ่มคำที่ซ่อน', GROK_ACTIONS: 'การดำเนินการของ Grok', HOME: 'หน้าแรก', LIKES: 'ความชอบ', MOST_RELEVANT: 'เกี่ยวข้องที่สุด', MUTE_THIS_CONVERSATION: 'ซ่อนบทสนทนานี้', POST_ALL: 'โพสต์ทั้งหมด', POST_UNAVAILABLE: 'โพสต์นี้ไม่สามารถใช้งานได้', PROFILE_SUMMARY: 'ข้อมูลส่วนตัวโดยย่อ', QUOTE: 'การอ้างอิง', QUOTES: 'คำพูด', QUOTE_TWEET: 'อ้างอิงทวีต', QUOTE_TWEETS: 'ทวีตและคำพูด', REPOST: 'รีโพสต์', REPOSTS: 'รีโพสต์', RETWEET: 'รีทวีต', RETWEETED_BY: 'ถูกรีทวีตโดย', RETWEETS: 'รีทวีต', SHARED: 'แบ่งปัน', SHARED_TWEETS: 'ทวีตที่แชร์', SHOW: 'แสดง', SHOW_MORE_REPLIES: 'แสดงการตอบกลับเพิ่มเติม', SORT_REPLIES_BY: 'จัดเรียงการตอบกลับโดย', TURN_OFF_QUOTE_TWEETS: 'ปิดทวีตและคำพูด', TURN_OFF_RETWEETS: 'ปิดรีทวีต', TURN_ON_RETWEETS: 'เปิดรีทวีต', TWEET: 'ทวีต', TWEETS: 'ทวีต', TWEET_ALL: 'ทวีตทั้งหมด', TWEET_INTERACTIONS: 'การโต้ตอบของทวีต', TWEET_YOUR_REPLY: 'ทวีตการตอบกลับของคุณ', TWITTER: 'ทวิตเตอร์', UNDO_RETWEET: 'ยกเลิกการรีทวีต', VIEW: 'ดู', WHATS_HAPPENING: 'มีอะไรเกิดขึ้นบ้าง', }, tr: { ADD_ANOTHER_TWEET: 'Başka bir Tweet ekle', ADD_MUTED_WORD: 'Sessize alınacak kelime ekle', GROK_ACTIONS: 'Grok işlemleri', HOME: 'Anasayfa', LIKES: 'Beğeni', MOST_RELEVANT: 'En alakalı', MUTE_THIS_CONVERSATION: 'Bu sohbeti sessize al', POST_ALL: 'Tümünü gönder', POST_UNAVAILABLE: 'Bu gönderi kullanılamıyor.', PROFILE_SUMMARY: 'Profil Özeti', QUOTE: 'Alıntı', QUOTES: 'Alıntılar', QUOTE_TWEET: 'Tweeti Alıntıla', QUOTE_TWEETS: 'Alıntı Tweetler', REPOST: 'Yeniden gönder', REPOSTS: 'Yeniden gönderiler', RETWEETED_BY: 'Retweetleyen(ler):', RETWEETS: 'Retweetler', SHARED: 'Paylaşılan', SHARED_TWEETS: 'Paylaşılan Tweetler', SHOW: 'Göster', SHOW_MORE_REPLIES: 'Daha fazla yanıt göster', SORT_REPLIES_BY: 'Yanıtları sıralama ölçütü', TURN_OFF_QUOTE_TWEETS: 'Alıntı Tweetleri kapat', TURN_OFF_RETWEETS: 'Retweetleri kapat', TURN_ON_RETWEETS: 'Retweetleri aç', TWEET: 'Tweetle', TWEETS: 'Tweetler', TWEET_ALL: 'Hepsini Tweetle', TWEET_INTERACTIONS: 'Tweet etkileşimleri', TWEET_YOUR_REPLY: 'Yanıtını Tweetle', UNDO_RETWEET: 'Retweeti Geri Al', VIEW: 'Görüntüle', WHATS_HAPPENING: 'Neler oluyor?', }, uk: { ADD_ANOTHER_TWEET: 'Додати ще один твіт', ADD_MUTED_WORD: 'Додати слово до списку ігнорування', GROK_ACTIONS: 'Дії Grok', HOME: 'Головна', LIKES: 'Вподобання', MOST_RELEVANT: 'Найактуальніші', MUTE_THIS_CONVERSATION: 'Ігнорувати цю розмову', POST_ALL: 'Опублікувати все', POST_UNAVAILABLE: 'Цей пост недоступний.', PROFILE_SUMMARY: 'Зведення профілю', QUOTE: 'Цитата', QUOTES: 'Цитати', QUOTE_TWEET: 'Цитувати твіт', QUOTE_TWEETS: 'Цитовані твіти', REPOST: 'Зробити репост', REPOSTS: 'Репости', RETWEET: 'Ретвітнути', RETWEETED_BY: 'Ретвіти', RETWEETS: 'Ретвіти', SHARED: 'Спільний', SHARED_TWEETS: 'Спільні твіти', SHOW: 'Показати', SHOW_MORE_REPLIES: 'Показати більше відповідей', SORT_REPLIES_BY: 'Сортувати відповіді за', TURN_OFF_QUOTE_TWEETS: 'Вимкнути цитовані твіти', TURN_OFF_RETWEETS: 'Вимкнути ретвіти', TURN_ON_RETWEETS: 'Увімкнути ретвіти', TWEET: 'Твіт', TWEETS: 'Твіти', TWEET_ALL: 'Твітнути все', TWEET_INTERACTIONS: 'Взаємодія твітів', TWEET_YOUR_REPLY: 'Твітніть відповідь', TWITTER: 'Твіттер', UNDO_RETWEET: 'Скасувати ретвіт', VIEW: 'Переглянути', WHATS_HAPPENING: 'Що відбувається?', }, ur: { ADD_ANOTHER_TWEET: 'ایک اور ٹویٹ شامل کریں', ADD_MUTED_WORD: 'میوٹ شدہ لفظ شامل کریں', HOME: 'ہوم', LIKES: 'لائک', MUTE_THIS_CONVERSATION: 'اس گفتگو کو میوٹ کریں', QUOTE: 'نقل کریں', QUOTES: 'منقول', QUOTE_TWEET: 'ٹویٹ کا حوالہ دیں', QUOTE_TWEETS: 'ٹویٹ کو نقل کرو', RETWEET: 'ریٹویٹ', RETWEETED_BY: 'جنہوں نے ریٹویٹ کیا', RETWEETS: 'ریٹویٹس', SHARED: 'مشترکہ', SHARED_TWEETS: 'مشترکہ ٹویٹس', SHOW: 'دکھائیں', SHOW_MORE_REPLIES: 'مزید جوابات دکھائیں', TURN_OFF_QUOTE_TWEETS: 'ٹویٹ کو نقل کرنا بند کریں', TURN_OFF_RETWEETS: 'ری ٹویٹس غیر فعال کریں', TURN_ON_RETWEETS: 'ری ٹویٹس غیر فعال کریں', TWEET: 'ٹویٹ', TWEETS: 'ٹویٹس', TWEET_ALL: 'سب کو ٹویٹ کریں', TWEET_INTERACTIONS: 'ٹویٹ تعاملات', TWEET_YOUR_REPLY: 'اپنا جواب ٹویٹ کریں', TWITTER: 'ٹوئٹر', UNDO_RETWEET: 'ری ٹویٹ کو کالعدم کریں', VIEW: 'دیکھیں', WHATS_HAPPENING: 'کیا ہو رہا ہے؟', }, vi: { ADD_ANOTHER_TWEET: 'Thêm Tweet khác', ADD_MUTED_WORD: 'Thêm từ tắt tiếng', GROK_ACTIONS: 'Hành động của Grok', HOME: 'Trang chủ', LIKES: 'Lượt thích', MOST_RELEVANT: 'Liên quan nhất', MUTE_THIS_CONVERSATION: 'Tắt tiếng cuộc trò chuyện này', POST_ALL: 'Đăng tất cả', POST_UNAVAILABLE: 'Không có bài đăng này.', PROFILE_SUMMARY: 'Tóm tắt hồ sơ', QUOTE: 'Trích dẫn', QUOTES: 'Trích dẫn', QUOTE_TWEET: 'Trích dẫn Tweet', QUOTE_TWEETS: 'Tweet trích dẫn', REPOST: 'Đăng lại', REPOSTS: 'Bài đăng lại', RETWEET: 'Tweet lại', RETWEETED_BY: 'Được Tweet lại bởi', RETWEETS: 'Các Tweet lại', SHARED: 'Đã chia sẻ', SHARED_TWEETS: 'Tweet được chia sẻ', SHOW: 'Hiện', SHOW_MORE_REPLIES: 'Hiển thị thêm trả lời', SORT_REPLIES_BY: 'Sắp xếp câu trả lời theo', TURN_OFF_QUOTE_TWEETS: 'Tắt Tweet trích dẫn', TURN_OFF_RETWEETS: 'Tắt Tweet lại', TURN_ON_RETWEETS: 'Bật Tweet lại', TWEETS: 'Tweet', TWEET_ALL: 'Đăng Tweet tất cả', TWEET_INTERACTIONS: 'Tương tác Tweet', TWEET_YOUR_REPLY: 'Đăng Tweet câu trả lời của bạn', UNDO_RETWEET: 'Hoàn tác Tweet lại', VIEW: 'Xem', WHATS_HAPPENING: 'Chuyện gì đang xảy ra?', }, 'zh-Hant': { ADD_ANOTHER_TWEET: '加入另一則推文', ADD_MUTED_WORD: '加入靜音文字', GROK_ACTIONS: 'Grok 動作', HOME: '首頁', LIKES: '喜歡的內容', MOST_RELEVANT: '最相關', MUTE_THIS_CONVERSATION: '將此對話靜音', POST_ALL: '全部發佈', POST_UNAVAILABLE: '此貼文無法查看。', PROFILE_SUMMARY: '個人檔案摘要', QUOTE: '引用', QUOTES: '引用', QUOTE_TWEET: '引用推文', QUOTE_TWEETS: '引用的推文', REPOST: '轉發', REPOSTS: '轉發', RETWEET: '轉推', RETWEETED_BY: '已被轉推', RETWEETS: '轉推', SHARED: '共享', SHARED_TWEETS: '分享的推文', SHOW: '顯示', SHOW_MORE_REPLIES: '顯示更多回覆', SORT_REPLIES_BY: '回覆排序方式', TURN_OFF_QUOTE_TWEETS: '關閉引用的推文', TURN_OFF_RETWEETS: '關閉轉推', TURN_ON_RETWEETS: '開啟轉推', TWEET: '推文', TWEETS: '推文', TWEET_ALL: '推全部內容', TWEET_INTERACTIONS: '推文互動', TWEET_YOUR_REPLY: '推你的回覆', UNDO_RETWEET: '取消轉推', VIEW: '查看', WHATS_HAPPENING: '有什麼新鮮事?', }, zh: { ADD_ANOTHER_TWEET: '添加另一条推文', ADD_MUTED_WORD: '添加要隐藏的字词', GROK_ACTIONS: 'Grok 操作', HOME: '主页', LIKES: '喜欢', MOST_RELEVANT: '最相关', MUTE_THIS_CONVERSATION: '隐藏此对话', POST_ALL: '全部发帖', POST_UNAVAILABLE: '这个帖子不可用。', PROFILE_SUMMARY: '个人资料概要', QUOTE: '引用', QUOTES: '引用', QUOTE_TWEET: '引用推文', QUOTE_TWEETS: '引用推文', REPOST: '转帖', REPOSTS: '转帖', RETWEET: '转推', RETWEETED_BY: '转推者', RETWEETS: '转推', SHARED: '共享', SHARED_TWEETS: '分享的推文', SHOW: '显示', SHOW_MORE_REPLIES: '显示更多回复', SORT_REPLIES_BY: '回复排序依据', TURN_OFF_QUOTE_TWEETS: '关闭引用推文', TURN_OFF_RETWEETS: '关闭转推', TURN_ON_RETWEETS: '开启转推', TWEET: '推文', TWEETS: '推文', TWEET_ALL: '全部发推', TWEET_INTERACTIONS: '推文互动', TWEET_YOUR_REPLY: '发布你的回复', UNDO_RETWEET: '撤销转推', VIEW: '查看', WHATS_HAPPENING: '有什么新鲜事?', }, } /** * @param {import("./types").LocaleKey} code * @returns {string} */ function getString(code) { return (locales[lang] || locales['en'])[code] || locales['en'][code]; } //#endregion //#region Constants /** @enum {string} */ const PagePaths = { ACCESSIBILITY_SETTINGS: '/settings/accessibility', ADD_MUTED_WORD: '/settings/add_muted_keyword', BOOKMARKS: '/i/bookmarks', COMPOSE_TWEET: '/compose/post', CONNECT: '/i/connect', DISPLAY_SETTINGS: '/settings/display', HOME: '/home', NOTIFICATION_TIMELINE: '/i/timeline', PROFILE_SETTINGS: '/settings/profile', SEARCH: '/search', TIMELINE_SETTINGS: '/home/pinned/edit', } /** @enum {string} */ const ModalPaths = { COMPOSE_DRAFTS: '/compose/post/unsent/drafts', COMPOSE_MEDIA: '/compose/post/media', COMPOSE_MESSAGE: '/messages/compose', COMPOSE_SCHEDULE: '/compose/post/schedule', COMPOSE_TWEET: '/compose/post', GIF_SEARCH: '/i/foundmedia/search', } /** @enum {string} */ const Selectors = { BLOCK_MENU_ITEM: '[data-testid="block"]', DESKTOP_TIMELINE_HEADER: 'div[data-testid="primaryColumn"] > div > div:first-of-type', DISPLAY_DONE_BUTTON_DESKTOP: '#layers button[role="button"]:not([aria-label])', DISPLAY_DONE_BUTTON_MOBILE: 'main button[role="button"]:not([aria-label])', MODAL_TIMELINE: 'section > h1 + div[aria-label] > div', MOBILE_TIMELINE_HEADER: 'div[data-testid="TopNavBar"]', MORE_DIALOG: 'div[aria-labelledby="modal-header"]', NAV_HOME_LINK: 'a[data-testid="AppTabBar_Home_Link"]', PRIMARY_COLUMN: 'div[data-testid="primaryColumn"]', PRIMARY_NAV_DESKTOP: 'header nav', PRIMARY_NAV_MOBILE: '#layers nav', PROMOTED_TWEET_CONTAINER: '[data-testid="placementTracking"]', SIDEBAR: 'div[data-testid="sidebarColumn"]', SIDEBAR_WRAPPERS: 'div[data-testid="sidebarColumn"] > div > div > div > div > div', SORT_REPLIES_PATH: 'svg path[d="M14 6V3h2v8h-2V8H3V6h11zm7 2h-3.5V6H21v2zM8 16v-3h2v8H8v-3H3v-2h5zm13 2h-9.5v-2H21v2z"]', TIMELINE: 'div[data-testid="primaryColumn"] section > h1 + div[aria-label] > div', TIMELINE_HEADING: 'h2[role="heading"]', TWEET: '[data-testid="tweet"]', VERIFIED_TICK: 'svg[data-testid="icon-verified"]', X_LOGO_PATH: 'svg path[d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"]', X_DARUMA_LOGO_PATH: 'svg path[d="M18.436 1.92h3.403l-7.433 8.495 8.745 11.563h-6.849l-5.363-7.012-6.136 7.012H1.4l7.951-9.088L.96 1.92h7.02l4.848 6.41 5.608-6.41zm-1.194 18.021h1.886L6.958 3.851H4.933l12.308 16.09z"]', } /** @enum {string} */ const Svgs = { BLUE_LOGO_PATH: 'M16.5 3H2v18h15c3.038 0 5.5-2.46 5.5-5.5 0-1.4-.524-2.68-1.385-3.65-.08-.09-.089-.22-.023-.32.574-.87.908-1.91.908-3.03C22 5.46 19.538 3 16.5 3zm-.796 5.99c.457-.05.892-.17 1.296-.35-.302.45-.684.84-1.125 1.15.004.1.006.19.006.29 0 2.94-2.269 6.32-6.421 6.32-1.274 0-2.46-.37-3.459-1 .177.02.357.03.539.03 1.057 0 2.03-.35 2.803-.95-.988-.02-1.821-.66-2.109-1.54.138.03.28.04.425.04.206 0 .405-.03.595-.08-1.033-.2-1.811-1.1-1.811-2.18v-.03c.305.17.652.27 1.023.28-.606-.4-1.004-1.08-1.004-1.85 0-.4.111-.78.305-1.11 1.113 1.34 2.775 2.22 4.652 2.32-.038-.17-.058-.33-.058-.51 0-1.23 1.01-2.22 2.256-2.22.649 0 1.235.27 1.647.7.514-.1.997-.28 1.433-.54-.168.52-.526.96-.992 1.23z', MUTE: '<g><path d="M18 6.59V1.2L8.71 7H5.5C4.12 7 3 8.12 3 9.5v5C3 15.88 4.12 17 5.5 17h2.09l-2.3 2.29 1.42 1.42 15.5-15.5-1.42-1.42L18 6.59zm-8 8V8.55l6-3.75v3.79l-6 6zM5 9.5c0-.28.22-.5.5-.5H8v6H5.5c-.28 0-.5-.22-.5-.5v-5zm6.5 9.24l1.45-1.45L16 19.2V14l2 .02v8.78l-6.5-4.06z"></path></g>', RETWEET: '<g><path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"></path></g>', RETWEETS_OFF: '<g><path d="M3.707 21.707l18-18-1.414-1.414-2.088 2.088C17.688 4.137 17.11 4 16.5 4H11v2h5.5c.028 0 .056 0 .084.002l-10.88 10.88c-.131-.266-.204-.565-.204-.882V7.551l2.068 1.93 1.365-1.462L4.5 3.882.068 8.019l1.365 1.462 2.068-1.93V16c0 .871.278 1.677.751 2.334l-1.959 1.959 1.414 1.414zM18.5 9h2v7.449l2.068-1.93 1.365 1.462-4.433 4.137-4.432-4.137 1.365-1.462 2.067 1.93V9zm-8.964 9l-2 2H13v-2H9.536z"></path></g>', TWITTER_FEATHER_PLUS_PATH: 'M23 3c-6.62-.1-10.38 2.421-13.05 6.03C7.29 12.61 6 17.331 6 22h2c0-1.007.07-2.012.19-3H12c4.1 0 7.48-3.082 7.94-7.054C22.79 10.147 23.17 6.359 23 3zm-7 8h-1.5v2H16c.63-.016 1.2-.08 1.72-.188C16.95 15.24 14.68 17 12 17H8.55c.57-2.512 1.57-4.851 3-6.78 2.16-2.912 5.29-4.911 9.45-5.187C20.95 8.079 19.9 11 16 11zM4 9V6H1V4h3V1h2v3h3v2H6v3H4z', TWITTER_HOME_ACTIVE_PATH: 'M12 1.696L.622 8.807l1.06 1.696L3 9.679V19.5C3 20.881 4.119 22 5.5 22h13c1.381 0 2.5-1.119 2.5-2.5V9.679l1.318.824 1.06-1.696L12 1.696zM12 16.5c-1.933 0-3.5-1.567-3.5-3.5s1.567-3.5 3.5-3.5 3.5 1.567 3.5 3.5-1.567 3.5-3.5 3.5z', TWITTER_HOME_INACTIVE_PATH: 'M12 9c-2.209 0-4 1.791-4 4s1.791 4 4 4 4-1.791 4-4-1.791-4-4-4zm0 6c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm0-13.304L.622 8.807l1.06 1.696L3 9.679V19.5C3 20.881 4.119 22 5.5 22h13c1.381 0 2.5-1.119 2.5-2.5V9.679l1.318.824 1.06-1.696L12 1.696zM19 19.5c0 .276-.224.5-.5.5h-13c-.276 0-.5-.224-.5-.5V8.429l7-4.375 7 4.375V19.5z', TWITTER_LOGO_PATH: 'M23.643 4.937c-.835.37-1.732.62-2.675.733.962-.576 1.7-1.49 2.048-2.578-.9.534-1.897.922-2.958 1.13-.85-.904-2.06-1.47-3.4-1.47-2.572 0-4.658 2.086-4.658 4.66 0 .364.042.718.12 1.06-3.873-.195-7.304-2.05-9.602-4.868-.4.69-.63 1.49-.63 2.342 0 1.616.823 3.043 2.072 3.878-.764-.025-1.482-.234-2.11-.583v.06c0 2.257 1.605 4.14 3.737 4.568-.392.106-.803.162-1.227.162-.3 0-.593-.028-.877-.082.593 1.85 2.313 3.198 4.352 3.234-1.595 1.25-3.604 1.995-5.786 1.995-.376 0-.747-.022-1.112-.065 2.062 1.323 4.51 2.093 7.14 2.093 8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602.91-.658 1.7-1.477 2.323-2.41z', X_HOME_ACTIVE_PATH: 'M21.591 7.146L12.52 1.157c-.316-.21-.724-.21-1.04 0l-9.071 5.99c-.26.173-.409.456-.409.757v13.183c0 .502.418.913.929.913H9.14c.51 0 .929-.41.929-.913v-7.075h3.909v7.075c0 .502.417.913.928.913h6.165c.511 0 .929-.41.929-.913V7.904c0-.301-.158-.584-.408-.758z', X_HOME_INACTIVE_PATH: 'M21.591 7.146L12.52 1.157c-.316-.21-.724-.21-1.04 0l-9.071 5.99c-.26.173-.409.456-.409.757v13.183c0 .502.418.913.929.913h6.638c.511 0 .929-.41.929-.913v-7.075h3.008v7.075c0 .502.418.913.929.913h6.639c.51 0 .928-.41.928-.913V7.904c0-.301-.158-.584-.408-.758zM20 20l-4.5.01.011-7.097c0-.502-.418-.913-.928-.913H9.44c-.511 0-.929.41-.929.913L8.5 20H4V8.773l8.011-5.342L20 8.764z', PLUS_PATH: 'M11 11V4h2v7h7v2h-7v7h-2v-7H4v-2h7z', } /** @enum {string} */ const Images = { TWITTER_FAVICON: 'data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAA0pJREFUWAntVk1oE1EQnnlJbFK3KUq9VJPYWgQVD/5QD0qpfweL1YJQoZAULBRPggp6kB78PQn14kHx0jRB0UO9REVFb1YqVBEsbZW2SbVS0B6apEnbbMbZ6qbZdTempqCHPAjvzcw3P5mdmfcAiquYgX+cAVwu/+5AdDMQnSPCHUhQA0hf+Rxy2OjicIvzm+qnKhito0qpb2wvJhWeJgCPP7oPELeHvdJ1VSGf3eOPnSWga0S0Qo9HxEkEusDBuNjbEca8G291nlBxmgDc/ukuIvAJxI6wr+yKCsq1ewLxQ2lZfpQLo8oQ4ZXdCkfnACrGWpyDCl+oQmVn5xuVPU102e2P3qoJkFOhzVb9S7KSnL5jJs/mI+As01PJFPSlZeFSZZoAGBRXBZyq9lk5NrC+e7pJ5en30c+JWk59pZ5vRDOuhAD381c/H/FKz1SMNgCE16rg505r5TT0uLqme93d0fbq+1SeLSeU83Ke0RHYFPGVPcjQfNDUwIa7M665+dQAEEjZoMwZMcEF9RxIDAgBQ2mCcqJ0Z0b+h4MNbZ4RnyOSDbNmE2iRk5jCNgIIckFoZAs4IgfLGrlKGjkzS16iwj6pV9I4mUvCPf73JVytH9nRJj24QHrqU8NCIWrMaGqAC+Ut/3ZzAS63cx4v2K/x/IvQBOCwWzu5KmJGwEJ5PIgeG9nQBDDcXPpFoDjJ7ThvBC6EZxXWkJG+JgAFwGM4KBAOcibeGCn8FQ/hyajXPmSk+1sACogn4hYk7OdiHDFSWipPkPWSmY6mCzIghEEuxJvcEYUvxIdhX2mvmSHDDPBF9AJRnDZTyp+P40671JYLbxiAohDxSTfQIg4oNxgPzCWPHaWQBViOf2jGqVwBaEaxGbAqOFMrp+SefC8eNhoFIY5lXzpmtnMGUB2IbU3JdIqVW9m5zcxINn/hAYKiIexdaTh4srHKORMAP0b28PNgJyGt5gvHzQVYx91QpVcwpRFl/p63HSR1DLbid1OcTpAJQOG7u+KH+aI5Qwj13IsamU5vkUSIc8uGLDa8OtoivV8U5HcydFLtT7hlSDVy2nfxI2Ibg9awuVU8IeJAOMF5m2B6jFs1tM5R9rS3GRP5uSuiihn4DzPwA7z7GDH+43gqAAAAAElFTkSuQmCC', TWITTER_PIP_FAVICON: 'data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAALASURBVHgB7VZNchJBFP5eM9FoRWV2WiZmbmBuIJ4g5ASBRWJlRXIC4ASQVUqxCo4QTwDegJzAiYlFXM1YZWmVQD9fQ6YyAwMMGBZW8i2G6e7He1+/3wHuOih4+fWieJhiKsirA0ZbE44fXZUaWDIGBH4/L+UUUB897DMfPf5ermKJUOaRIhTiDlNEBSwZlnkwY2vCuYOEWD/xMrCoKC41utISRlcc3Or2dfnqwHbDcj9X0fbztn9DAHxOoM0xrZILSIBXtR9F0VGKbJIhz7kVi3Lr770yAz4p2iYm188/awVi6lo4Ns4mETEDLz94uTHjIxDDRaWoohhOSjwi/9mKEFjtlKsayAuRM7M2HmFJwCRVIIqLSAAJjS822v0Vaip1E1oKC6XrXtrExjnxnJ6ldoVKFj0+ujywW3FKTTzJoibmAXP+Yt9uBEsrfLbWRelJzS/0B8z4WoKa6zW/1dd83Hlnn0Z0peAQkqNHvNPZi+qIELBWUNU97LLJ4hDESMZSlNmo+b5UTEvC85m0JCipTQREE+BhdzypIwSkLvyn4LKYrEzQkSZCloiyw+xJbnygfxX+VAJrPWnBoC9ixBXdDm4XflD7YajIinFq3L0E45J7fBa3HyEg7mhgeWjPJODu223J/iMsATzhcmp04+ueXTW1OsiD2zIuVfNNLockBAyIkdaaPxHGs3YR0JTQWnGbWkFCQZX5imwCmBoX++nGpONYD1zu2S0a9IN/g3jSNcNnqsy0ww2ZdPJzCKLXWAAy1N6ay2BRAgEcGZ+aqDnaoqdbjw6dhQgYwz1S2xKOQyQ0Phy7vDPr5iH5ITY+elmtpddLFyQzZBTP3xGl3FJ95NzQJ1hiAgMSw5jnJOZvMA/EMBNKSW89kUAAp+45+g+yojRjljL9NoP4GxdLYzk334vy3lYP0HBjhsw97vHf4C/b8RLHAOr+CQAAAABJRU5ErkJggg==', } const THEME_BLUE = 'rgb(29, 155, 240)' const THEME_COLORS = new Map([ ['blue500', THEME_BLUE], ['yellow500', 'rgb(255, 212, 0)'], ['magenta500', 'rgb(249, 24, 128)'], ['purple500', 'rgb(120, 86, 255)'], ['orange500', 'rgb(255, 122, 0)'], ['green500', 'rgb(0, 186, 124)'], ]) const HIGH_CONTRAST_LIGHT = new Map([ ['blue500', 'rgb(0, 56, 134)'], ['yellow500', 'rgb(111, 62, 0)'], ['magenta500', 'rgb(137, 10, 70)'], ['purple500', 'rgb(82, 52, 183)'], ['orange500', 'rgb(137, 43, 0)'], ['green500', 'rgb(0, 97, 61)'], ]) const HIGH_CONTRAST_DARK = new Map([ ['blue500', 'rgb(107, 201, 251)'], ['yellow500', 'rgb(255, 235, 107)'], ['magenta500', 'rgb(251, 112, 176)'], ['purple500', 'rgb(172, 151, 255)'], ['orange500', 'rgb(255, 173, 97)'], ['green500', 'rgb(97, 214, 163)'], ]) const COMPOSE_TWEET_MODAL_PAGES = new Set([ ModalPaths.COMPOSE_DRAFTS, ModalPaths.COMPOSE_MEDIA, ModalPaths.COMPOSE_SCHEDULE, ModalPaths.GIF_SEARCH, ]) // <body> pseudo-selector for pages the full-width content feature works on const FULL_WIDTH_BODY_PSEUDO = ':is(.Community, .List, .HomeTimeline)' // Matches any notification count at the start of the title const TITLE_NOTIFICATION_RE = /^\(\d+\+?\) / // The Communities nav item takes you to /yourusername/communities const URL_COMMUNITIES_RE = /^\/[a-zA-Z\d_]{1,20}\/communities(?:\/explore)?\/?$/ const URL_COMMUNITY_RE = /^\/i\/communities\/\d+(?:\/about)?\/?$/ const URL_COMMUNITY_MEMBERS_RE = /^\/i\/communities\/\d+\/(?:members|moderators)\/?$/ const URL_DISCOVER_COMMUNITIES_RE = /^\/i\/communities\/suggested\/?/ const URL_LIST_RE = /\/i\/lists\/\d+\/?$/ const URL_LISTS_RE = /^\/[a-zA-Z\d_]{1,20}\/lists\/?$/ const URL_MEDIA_RE = /\/(?:photo|video)\/\d\/?$/ const URL_MEDIAVIEWER_RE = /^\/[a-zA-Z\d_]{1,20}\/status\/\d+\/mediaviewer$/i // Matches URLs which show one of the tabs on a user profile page const URL_PROFILE_RE = /^\/([a-zA-Z\d_]{1,20})(?:\/(affiliates|with_replies|superfollows|highlights|articles|media|likes))?\/?$/ // Matches URLs which show a user's Followers you know / Followers / Following tab const URL_PROFILE_FOLLOWS_RE = /^\/[a-zA-Z\d_]{1,20}\/(?:verified_followers|follow(?:ing|ers|ers_you_follow)|creator-subscriptions\/subscriptions)\/?$/ const URL_TWEET_RE = /^\/([a-zA-Z\d_]{1,20})\/status\/(\d+)\/?$/ const URL_TWEET_ENGAGEMENT_RE = /^\/[a-zA-Z\d_]{1,20}\/status\/\d+\/(quotes|retweets|reposts|likes)\/?$/ // The Twitter Media Assist exension adds a new button at the end of the action // bar (#346) const TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR = '.tva-download-icon, .tva-modal-download-icon' //#endregion //#region Variables /** * The quoted Tweet associated with a caret menu that's just been opened. * @type {import("./types").QuotedTweet} */ let quotedTweet = null /** `true` when a 'Block @${user}' menu item was seen in the last popup. */ let blockMenuItemSeen = false /** `true` if the user has used the "Sort replies by" menu */ let userSortedReplies = false /** Notification count in the title (including trailing space), e.g. `'(1) '`. */ let currentNotificationCount = '' /** The last notification count we hid from the title. */ let hiddenNotificationCount = '' /** Title of the current page, without the `' / Twitter'` suffix. */ let currentPage = '' /** Current `location.pathname`. */ let currentPath = '' /** * React Native stylesheet rule for the blur filter for sensitive content. * @type {CSSStyleRule} */ let filterBlurRule = null /** * React Native stylesheett rule for the Chirp font-family. * @type {CSSStyleRule} */ let fontFamilyRule = null /** @type {string} */ let fontSize = null /** Set to `true` when a Home/Following heading or Home nav link is used. */ let homeNavigationIsBeingUsed = false /** Set to `true` when the media modal is open on desktop. */ let isDesktopMediaModalOpen = false /** Set to `true` when the compose tweet modal is open on desktop. */ let isDesktopComposeTweetModalOpen = false /** @type {HTMLElement} */ let $desktopComposeTweetModalPopup = null /** * Cache for the last page title which was used for the Home timeline. * @type {string} */ let lastHomeTimelineTitle = null /** * MutationObservers active on the current modal. * @type {import("./types").Disconnectable[]} */ let modalObservers = [] /** * `true` after the app has initialised. * @type {boolean} */ let observingPageChanges = false /** * MutationObservers active on the current page, or anything else we want to * clean up when the user moves off the current page. * @type {import("./types").NamedMutationObserver[]} */ let pageObservers = [] /** @type {number} */ let selectedHomeTabIndex = -1 /** * Title for the fake timeline used to separate out retweets and quote tweets. * @type {string} */ let separatedTweetsTimelineTitle = null /** * The current "Color" setting. * @type {string} */ let themeColor = THEME_BLUE /** * Tab to switch to after navigating to the Tweet interactions page. * @type {string} */ let tweetInteractionsTab = null /** * `true` when "For you" was the last tab selected on the Home timeline. */ let wasForYouTabSelected = false function isOnAccessibilitySettingsPage() { return currentPath == PagePaths.ACCESSIBILITY_SETTINGS } function isOnBookmarksPage() { return currentPath.startsWith(PagePaths.BOOKMARKS) } function isOnCommunitiesPage() { return URL_COMMUNITIES_RE.test(currentPath) } function isOnCommunityPage() { return URL_COMMUNITY_RE.test(currentPath) } function isOnCommunityMembersPage() { return URL_COMMUNITY_MEMBERS_RE.test(currentPath) } function isOnDiscoverCommunitiesPage() { return URL_DISCOVER_COMMUNITIES_RE.test(currentPath) } function isOnDisplaySettingsPage() { return currentPath == PagePaths.DISPLAY_SETTINGS } function isOnExplorePage() { return currentPath == '/explore' || currentPath.startsWith('/explore/') } function isOnFollowListPage() { return URL_PROFILE_FOLLOWS_RE.test(currentPath) } function isOnIndividualTweetPage() { return URL_TWEET_RE.test(currentPath) } function isOnListPage() { return URL_LIST_RE.test(currentPath) } function isOnListsPage() { return URL_LISTS_RE.test(currentPath) } function isOnHomeTimelinePage() { return currentPath == PagePaths.HOME } function isOnMessagesPage() { return currentPath.startsWith('/messages') } function isOnNotificationsPage() { return currentPath.startsWith('/notifications') } function isOnProfilePage() { let profilePathUsername = currentPath.match(URL_PROFILE_RE)?.[1] if (!profilePathUsername) return false // twitter.com/user and its sub-URLs put @user in the title return currentPage.toLowerCase().includes(`${ltr ? '@' : ''}${profilePathUsername.toLowerCase()}${!ltr ? '@' : ''}`) } function isOnQuoteTweetsPage() { let match = currentPath.match(URL_TWEET_ENGAGEMENT_RE) return match?.[1] == 'quotes' } function isOnSearchPage() { return currentPath.startsWith('/search') || currentPath.startsWith('/hashtag/') } function isOnSeparatedTweetsTimeline() { return currentPage == separatedTweetsTimelineTitle } function isOnSettingsPage() { return currentPath.startsWith('/settings') } function shouldHideSidebar() { return isOnExplorePage() || isOnDiscoverCommunitiesPage() } function shouldShowSeparatedTweetsTab() { return config.retweets == 'separate' || config.quoteTweets == 'separate' } //#endregion //#region Utility functions /** * @param {string} role * @returns {HTMLStyleElement} */ function addStyle(role) { let $style = document.createElement('style') $style.dataset.insertedBy = 'control-panel-for-twitter' $style.dataset.role = role document.head.appendChild($style) return $style } /** * @param {Element} $svg */ function blueCheck($svg) { if (!$svg) { warn('blueCheck was given', $svg) return } $svg.classList.add('tnt_blue_check') // Safari doesn't support using `d: path(…)` to replace paths in an SVG, so // we have to manually patch the path in it. if (isSafari && config.twitterBlueChecks == 'replace') { $svg.firstElementChild.firstElementChild.setAttribute('d', Svgs.BLUE_LOGO_PATH) } } /** * @param {Element} $svgPath */ function twitterLogo($svgPath) { // Safari doesn't support using `d: path(…)` to replace paths in an SVG, so // we have to manually patch the path in it. $svgPath.setAttribute('d', Svgs.TWITTER_LOGO_PATH) $svgPath.classList.add('tnt_logo') } /** * @param {Element} $svgPath */ function homeIcon($svgPath) { // Safari doesn't support using `d: path(…)` to replace paths in an SVG, so // we have to manually patch the path in it. let replacementPath = { [Svgs.X_HOME_ACTIVE_PATH]: Svgs.TWITTER_HOME_ACTIVE_PATH, [Svgs.X_HOME_INACTIVE_PATH]: Svgs.TWITTER_HOME_INACTIVE_PATH, }[$svgPath.getAttribute('d')] if (replacementPath) { $svgPath.setAttribute('d', replacementPath) } } /** * @param {string} str * @returns {string} */ function dedent(str) { str = str.replace(/^[ \t]*\r?\n/, '') let indent = /^[ \t]+/m.exec(str) if (indent) str = str.replace(new RegExp('^' + indent[0], 'gm'), '') return str.replace(/(\r?\n)[ \t]+$/, '$1') } /** * @param {string} name * @param {import("./types").Disconnectable[]} observers */ function disconnectObserver(name, observers) { for (let i = observers.length -1; i >= 0; i--) { let observer = observers[i] if ('name' in observer && observer.name == name) { observer.disconnect() observers.splice(i, 1) log(`disconnected ${name} ${observers === pageObservers ? 'page' : 'modal'} observer`) } } } function disconnectModalObserver(name) { disconnectObserver(name, modalObservers) } function disconnectAllModalObservers() { if (modalObservers.length > 0) { log( `disconnecting ${modalObservers.length} modal observer${s(modalObservers.length)}`, modalObservers.map(observer => observer['name']) ) modalObservers.forEach(observer => observer.disconnect()) modalObservers = [] } } function disconnectPageObserver(name) { disconnectObserver(name, pageObservers) } /** * @param {MutationRecord[]} mutations * @param {($el: Node) => boolean | HTMLElement} fn - return `true` to use [$el] * as the r###lt, or return a different HTMLElement to use it as the r###lt. * @returns {Node | HTMLElement | null} */ function findAddedNode(mutations, fn) { for (let mutation of mutations) { for (let el of mutation.addedNodes) { let r###lt = fn(el) if (r###lt) { return r###lt === true ? el : r###lt } } } return null } /** * @param {string} selector * @param {{ * name?: string * stopIf?: () => boolean * timeout?: number * context?: Document | HTMLElement * }?} options * @returns {Promise<HTMLElement | null>} */ function getElement(selector, { name = null, stopIf = null, timeout = Infinity, context = document, } = {}) { return new Promise((resolve) => { let startTime = Date.now() let rafId let timeoutId function stop($element, reason) { if ($element == null) { warn(`stopped waiting for ${name || selector} after ${reason}`) } else if (Date.now() > startTime) { log(`${name || selector} appeared after ${Date.now() - startTime}ms`) } if (rafId) { cancelAnimationFrame(rafId) } if (timeoutId) { clearTimeout(timeoutId) } resolve($element) } if (timeout !== Infinity) { timeoutId = setTimeout(stop, timeout, null, `${timeout}ms timeout`) } function queryElement() { let $element = context.querySelector(selector) if ($element) { stop($element) } else if (stopIf?.() === true) { stop(null, 'stopIf condition met') } else { rafId = requestAnimationFrame(queryElement) } } queryElement() }) } function getState() { let wrapped = $reactRoot.firstElementChild['wrappedJSObject'] || $reactRoot.firstElementChild let reactPropsKey = Object.keys(wrapped).find(key => key.startsWith('__reactProps')) if (reactPropsKey) { let state = wrapped[reactPropsKey].children?.props?.children?.props?.store?.getState() if (state) return state warn('React state not found') } else { warn('React prop key not found') } } function hasNewLayout() { return getState()?.featureSwitch?.user?.config?.rweb_sourcemap_migration?.value } function getNotificationCount() { let state = getState() if (!state || !state.badgeCount) { warn('could not get notification count from state') return 0 } return state.badgeCount.unreadDMCount + state.badgeCount.unreadNTabCount; } function getStateEntities() { let state = getState() if (state) { if (state.entities) return state.entities warn('React state entities not found') } } function getThemeColorFromState() { let localState = getState().settings?.local let color = localState?.themeColor let highContrast = localState?.highContrastEnabled $body.classList.toggle('HighContrast', highContrast) if (color) { if (THEME_COLORS.has(color)) { let colors = THEME_COLORS if (highContrast) colors = getColorScheme() == 'Default' ? HIGH_CONTRAST_LIGHT : HIGH_CONTRAST_DARK return colors.get(color) } warn(color, 'not found in THEME_COLORS') } else { warn('could not get settings.local.themeColor from React state') } } /** * Gets cached tweet info from React state. */ function getTweetInfo(id) { let tweetEntities = getStateEntities()?.tweets?.entities if (tweetEntities) { let tweetInfo = tweetEntities[id] if (!tweetInfo) { warn('tweet info not found') } return tweetInfo } else { warn('tweet entities not found') } } /** * Gets cached user info from React state. * @returns {import("./types").UserInfoObject} */ function getUserInfo() { /** @type {import("./types").UserInfoObject} */ let userInfo = {} let userEntities = getStateEntities()?.users?.entities if (userEntities) { for (let user of Object.values(userEntities)) { userInfo[user.screen_name] = { following: user.following, followedBy: user.followed_by, followersCount: user.followers_count, } } } else { warn('user entities not found') } return userInfo } /** * @param {import("./types").Disconnectable[]} observers * @param {string} name */ function isObserving(observers, name) { return observers.some(observer => 'name' in observer && observer.name == name) } function log(...args) { if (debug) { let page = currentPage?.replace(/(\r?\n)+/g, ' ') console.log(`${page ? `(${ page.length < 42 ? page : page.slice(0, 42) + '…' })` : ''}`, ...args) } } function warn(...args) { if (debug) { console.log(`❗ ${currentPage ? `(${currentPage})` : ''}`, ...args) } } function error(...args) { console.log(`❌ ${currentPage ? `(${currentPage})` : ''}`, ...args) } /** * @param {() => boolean} condition * @returns {() => boolean} */ function not(condition) { return () => !condition() } /** * Convenience wrapper for the MutationObserver API - the callback is called * immediately to support using an observer and its options as a trigger for any * change, without looking at MutationRecords. * @param {Node} $element * @param {MutationCallback} callback * @param {string} name * @param {MutationObserverInit} options * @returns {import("./types").NamedMutationObserver} */ function observeElement($element, callback, name, options = {childList: true}) { if (name) { if (options.childList && callback.length > 0) { log(`observing ${name}`, $element) } else { log (`observing ${name}`) } } let observer = new MutationObserver(callback) callback([], observer) observer.observe($element, options) observer['name'] = name return observer } /** * @param {string} page * @returns {() => boolean} */ function pageIsNot(page) { return function() { let pageChanged = page != currentPage if (pageChanged) { log('pageIsNot', {page, currentPage}) } return pageChanged } } /** * @param {string} path * @returns {() => boolean} */ function pathIsNot(path) { return () => path != currentPath } /** * @param {number} n * @returns {string} */ function s(n) { return n == 1 ? '' : 's' } /** * @param {Element} $tweetButtonText */ function setTweetButtonText($tweetButtonText) { let currentText = $tweetButtonText.textContent if (currentText == getString('TWEET') || currentText == getString('TWEET_ALL')) return $tweetButtonText.textContent = currentText == getString('POST_ALL') ? getString('TWEET_ALL') : getString('TWEET') } function storeConfigChanges(changes) { window.postMessage({type: 'tntConfigChange', changes}) } //#endregion //#region Global observers const checkReactNativeStylesheet = (() => { /** @type {number} */ let startTime return function checkReactNativeStylesheet() { startTime ??= Date.now() let $style = /** @type {HTMLStyleElement} */ (document.querySelector('style#react-native-stylesheet')) if (!$style) { warn('React Native stylesheet not found') return } for (let rule of $style.sheet.cssRules) { if (!(rule instanceof CSSStyleRule)) continue if (fontFamilyRule == null && rule.style.fontFamily?.includes('TwitterChirp') && !rule.style.fontFamily.includes('TwitterChirpExtendedHeavy')) { fontFamilyRule = rule log('found Chirp fontFamily CSS rule in React Native stylesheet') configureFont() } if (filterBlurRule == null && rule.style.filter?.includes('blur(30px)')) { filterBlurRule = rule log('found filter: blur(30px) rule in React Native stylesheet', filterBlurRule) configureDynamicCss() } } let elapsedTime = Date.now() - startTime if (fontFamilyRule == null || filterBlurRule == null) { if (elapsedTime < 3000) { setTimeout(checkReactNativeStylesheet, 100) } else { warn(`stopped checking React Native stylesheet after ${elapsedTime}ms`) } } else { log(`finished checking React Native stylesheet in ${elapsedTime}ms`) } } })() /** * When the "Background" setting is changed, <body>'s backgroundColor is changed * and the app is re-rendered, so we need to re-process the current page. */ function observeBodyBackgroundColor() { let lastBackgroundColor = null observeElement($body, () => { let backgroundColor = $body.style.backgroundColor if (backgroundColor == lastBackgroundColor) return $body.classList.toggle('Default', backgroundColor == 'rgb(255, 255, 255)') $body.classList.toggle('Dim', backgroundColor == 'rgb(21, 32, 43)') $body.classList.toggle('LightsOut', backgroundColor == 'rgb(0, 0, 0)') if (lastBackgroundColor != null) { log('Background setting changed - re-processing current page') observePopups() observeSideNavTweetButton() processCurrentPage() } lastBackgroundColor = backgroundColor }, '<body> style attribute for background colour changes', { attributes: true, attributeFilter: ['style'] }) } /** * @param {HTMLElement} $popup */ async function observeDesktopComposeTweetModal($popup) { if (!config.replaceLogo) return let $mask = await getElement('[data-testid="twc-cc-mask"]', { context: $popup, name: 'Compose Tweet modal mask', stopIf: () => !isDesktopComposeTweetModalOpen }) if (!$mask) return let $tweetButtonText = $popup.querySelector('button[data-testid="tweetButton"] span > span') if ($tweetButtonText) { setTweetButtonText($tweetButtonText) } modalObservers.push( observeElement($mask.nextElementSibling, () => { disconnectModalObserver('Modal Tweet editor root (for placeholder)') let $editorRoots = $popup.querySelectorAll('.DraftEditor-root') $editorRoots.forEach((/** @type {HTMLElement} */ $editorRoot, index) => { $editorRoot.setAttribute('data-placeholder', getString(index == 0 ? 'WHATS_HAPPENING' : 'ADD_ANOTHER_TWEET')) observeDesktopTweetEditorPlaceholder($editorRoot, { name: 'Modal Tweet editor root (for placeholder)', observers: modalObservers, }) }) }, 'Compose Tweet modal Tweets container (for Tweets being added or removed)') ) // The Tweet button gets moved around when Tweets are added or removed modalObservers.push( observeElement($mask.nextElementSibling, (mutations) => { for (let mutation of mutations) { for (let $addedNode of mutation.addedNodes) { if (!($addedNode instanceof HTMLElement) || $addedNode.nodeName != 'DIV') continue let $tweetButtonText = $addedNode.querySelector('button[data-testid="tweetButton"] span > span') if ($tweetButtonText) { setTweetButtonText($tweetButtonText) } } } }, 'Compose Tweet modal contents (for Tweet button moving)', { childList: true, subtree: true, }) ) } /** * The timeline Tweet box is removed when you navigate to a pinned Communities * tab and re-added when you navigate to another Home timeline tab. */ async function observeDesktopHomeTimelineTweetBox() { let $container = await getElement('div[data-testid="primaryColumn"] > div', { name: 'Home timeline Tweet box container', stopIf: pageIsNot(currentPage), }) if (!$container) return /** * @param {HTMLElement} $tweetBox */ async function observeTweetBox($tweetBox) { $tweetBox.classList.add('TweetBox') if (config.replaceLogo) { // Restore "What's happening?" placeholder let $editorRoot = await getElement('.DraftEditor-root', { context: $tweetBox, name: 'Tweet box editor root', stopIf: pageIsNot(currentPage), }) if (!$editorRoot) return observeDesktopTweetEditorPlaceholder($editorRoot, { observers: pageObservers, placeholder: getString('WHATS_HAPPENING'), }) tweakTweetButton() } } /** @type {HTMLElement} */ let $timelineTweetBox = $container.querySelector(':scope > div:has([data-testid^="tweetTextarea"]') if ($timelineTweetBox) { log('Home timeline Tweet box present') observeTweetBox($timelineTweetBox) } pageObservers.push( observeElement($container, (mutations) => { for (let mutation of mutations) { for (let $addedNode of mutation.addedNodes) { if (!($addedNode instanceof HTMLElement)) continue if ($addedNode.querySelector('[data-testid^="tweetTextarea"]')) { log('Home timeline Tweet box appeared') $timelineTweetBox = $addedNode observeTweetBox($timelineTweetBox) } } for (let $removedNode of mutation.removedNodes) { if (!($removedNode instanceof HTMLElement)) continue if ($removedNode === $timelineTweetBox) { log('Home timeline Tweet box removed') $timelineTweetBox = null disconnectPageObserver('Tweet box editor root') } } } }, 'Home timeline Tweet box container') ) } /** * @param {HTMLElement} $popup */ async function observeDesktopModalTimeline($popup) { // Media modals remember if they were previously collapsed, so we could be // waiting for the initial timeline to be either rendered or expanded. let $initialTimeline = await getElement(Selectors.MODAL_TIMELINE, { context: $popup, name: 'initial modal timeline', stopIf: () => !isDesktopMediaModalOpen, }) if ($initialTimeline == null) return /** * @param {HTMLElement} $timeline */ function observeModalTimelineItems($timeline) { disconnectModalObserver('modal timeline') modalObservers.push( observeElement($timeline, () => onIndividualTweetTimelineChange($timeline, {observers: modalObservers}), 'modal timeline') ) // If other media in the modal is clicked, the timeline is replaced. disconnectModalObserver('modal timeline parent') modalObservers.push( observeElement($timeline.parentElement, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {HTMLElement} */ $newTimeline) => { log('modal timeline replaced') disconnectModalObserver('modal timeline') modalObservers.push( observeElement($newTimeline, () => onIndividualTweetTimelineChange($newTimeline, {observers: modalObservers}), 'modal timeline') ) }) }) }, 'modal timeline parent') ) } /** * @param {HTMLElement} $timeline */ function observeModalTimeline($timeline) { // If the inital timeline doesn't have a style attribute it's a placeholder if ($timeline.hasAttribute('style')) { observeModalTimelineItems($timeline) } else { log('waiting for modal timeline') let startTime = Date.now() modalObservers.push( observeElement($timeline.parentElement, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {HTMLElement} */ $timeline) => { disconnectModalObserver('modal timeline parent') if (Date.now() > startTime) { log(`modal timeline appeared after ${Date.now() - startTime}ms`, $timeline) } observeModalTimelineItems($timeline) }) }) }, 'modal timeline parent') ) } } // The modal timeline can be expanded and collapsed let $expandedContainer = $initialTimeline.closest('[aria-expanded="true"]') modalObservers.push( observeElement($expandedContainer.parentElement, async (mutations) => { if (mutations.some(mutation => mutation.removedNodes.length > 0)) { log('modal timeline collapsed') disconnectModalObserver('modal timeline parent') disconnectModalObserver('modal timeline') } else if (mutations.some(mutation => mutation.addedNodes.length > 0)) { log('modal timeline expanded') let $timeline = await getElement(Selectors.MODAL_TIMELINE, { context: $popup, name: 'expanded modal timeline', stopIf: () => !isDesktopMediaModalOpen, }) if ($timeline == null) return observeModalTimeline($timeline) } }, 'collapsible modal timeline container') ) observeModalTimeline($initialTimeline) } const observeFavicon = (() => { /** @type {HTMLLinkElement} */ let $shortcutIcon async function observeFavicon() { $shortcutIcon = /** @type {HTMLLinkElement} */ (await getElement('link[rel~="icon"]', { name: 'shortcut icon' })) observeElement($shortcutIcon, () => { let href = $shortcutIcon.href if (config.replaceLogo) { // Once we replace the favicon, Twitter stops updating it when // notification status changes, so this only handles initial switchover // to the Twitter version of the icon. if (href.startsWith('data:')) return let icon = config.hideNotifications != 'ignore' && href.includes('-pip') ? ( Images.TWITTER_PIP_FAVICON ) : ( Images.TWITTER_FAVICON ) $shortcutIcon.href = icon } else { // If we're hiding notifications, detect when Twitter tries to use the // pip version and switch back. if (config.hideNotifications != 'ignore' && href.includes('-pip')) { $shortcutIcon.href = href.replace('-pip', '') } } }, 'shortcut icon href', { attributes: true, attributeFilter: ['href'] }) } observeFavicon.forceUpdate = function(showPip) { let href = $shortcutIcon.href if (config.replaceLogo) { href = config.hideNotifications == 'ignore' && showPip ? ( Images.TWITTER_PIP_FAVICON ) : ( Images.TWITTER_FAVICON ) } else { href = `//abs.twimg.com/favicons/twitter${ config.hideNotifications == 'ignore' && showPip ? '-pip' : '' }.3.ico` } if (href != $shortcutIcon.href) { $shortcutIcon.href = href } } return observeFavicon })() /** * Twitter displays popups in the #layers element. It also reuses open popups * in certain cases rather than creating one from scratch, so we also need to * deal with nested popups, e.g. if you hover over the caret menu in a Tweet, a * popup will be created to display a "More" tootip and clicking to open the * menu will create a nested element in the existing popup, whereas clicking the * caret quickly without hovering over it will display the menu in new popup. * Use of nested popups can also differ between desktop and mobile, so features * need to be mindful of that. */ const observePopups = (() => { /** @type {MutationObserver} */ let popupObserver /** @type {WeakMap<HTMLElement, {disconnect()}>} */ let nestedObservers = new WeakMap() return async function observePopups() { if (popupObserver) { popupObserver.disconnect() popupObserver = null } let $layers = await getElement('#layers', { name: 'layers', }) // There can be only one if (popupObserver) { popupObserver.disconnect() } popupObserver = observeElement($layers, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {HTMLElement} */ $el) => { let nestedObserver = onPopup($el) if (nestedObserver) { nestedObservers.set($el, nestedObserver) } }) mutation.removedNodes.forEach((/** @type {HTMLElement} */ $el) => { if (nestedObservers.has($el)) { nestedObservers.get($el).disconnect() nestedObservers.delete($el) } }) }) }, 'popup container') } })() async function observeTitle() { let $title = await getElement('title', {name: '<title>'}) observeElement($title, () => { let title = $title.textContent if (title.match(/^Intervention for (X|Twitter)$/)) { log('Ignoring one sec extension title') return } if (config.replaceLogo && (ltr ? /X$/ : /^(?:\(\d+\+?\) )?X/).test(title)) { title = title.replace(ltr ? /X$/ : 'X', getString('TWITTER')) } if (config.hideNotifications != 'ignore' && TITLE_NOTIFICATION_RE.test(title)) { hiddenNotificationCount = TITLE_NOTIFICATION_RE.exec(title)[0] title = title.replace(TITLE_NOTIFICATION_RE, '') } if (title != $title.textContent) { document.title = title // If Twitter is opened in the background, changing the title might not // re-fire the title MutationObserver, preventing the initial page from // being processed. if (!currentPage) { onTitleChange(title) } return } if (observingPageChanges) { onTitleChange(title) } }, '<title>') } //#endregion //#region Page observers async function observeSidebar() { let $primaryColumn = await getElement(Selectors.PRIMARY_COLUMN, { name: 'primary column' }) let $sidebarContainer = $primaryColumn.parentElement pageObservers.push( observeElement($sidebarContainer, () => { let $sidebar = $sidebarContainer.querySelector(Selectors.SIDEBAR) log(`sidebar ${$sidebar ? 'appeared' : 'disappeared'}`) $body.classList.toggle('Sidebar', Boolean($sidebar)) if ($sidebar && config.twitterBlueChecks != 'ignore' && !isOnSearchPage() && !isOnExplorePage()) { observeSearchForm() } }, 'sidebar container') ) } const observeSideNavTweetButton = (() => { /** @type {MutationObserver} */ let observer return async function observeSideNavTweetButton() { if (observer) { observer.disconnect() observer = null } if (!desktop || !config.replaceLogo) return // This element is updated when text is added or removed on resize let $buttonTextContainer = await getElement('a[data-testid="SideNav_NewTweet_Button"] > div > span', { name: 'sidenav tweet button text container', }) observer = observeElement($buttonTextContainer, () => { if ($buttonTextContainer.childElementCount > 0) { let $buttonText = /** @type {HTMLElement} */ ($buttonTextContainer.querySelector('span > span')) if ($buttonText) { setTweetButtonText($buttonText) } else { warn('could not find tweet button text') } } }, 'sidenav tweet button') } })() async function observeSearchForm() { let $searchForm = await getElement('form[role="search"]', { name: 'search form', stopIf: pageIsNot(currentPage), // The sidebar on Profile pages can be really slow timeout: 2000, }) if (!$searchForm) return let $r###lts = /** @type {HTMLElement} */ ($searchForm.lastElementChild) pageObservers.push( observeElement($r###lts, () => { processBlueChecks($r###lts) }, 'search r###lts', {childList: true, subtree: true}) ) } /** * @param {string} page * @param {import("./types").TimelineOptions?} options */ async function observeTimeline(page, options = {}) { let { isTabbed = false, onTabChanged = null, onTimelineAppeared = null, tabbedTimelineContainerSelector = null, timelineSelector = Selectors.TIMELINE, } = options let $timeline = await getElement(timelineSelector, { name: 'initial timeline', stopIf: pageIsNot(page), }) if ($timeline == null) return /** * @param {HTMLElement} $timeline */ function observeTimelineItems($timeline) { disconnectPageObserver('timeline') pageObservers.push( observeElement($timeline, () => onTimelineChange($timeline, page, options), 'timeline') ) onTimelineAppeared?.() if (isTabbed) { // When a tab which has been viewed before is revisited, the timeline is // replaced. disconnectPageObserver('timeline parent') pageObservers.push( observeElement($timeline.parentElement, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {HTMLElement} */ $newTimeline) => { disconnectPageObserver('timeline') log('tab changed') onTabChanged?.() pageObservers.push( observeElement($newTimeline, () => onTimelineChange($newTimeline, page, options), 'timeline') ) }) }) }, 'timeline parent') ) } } // If the inital timeline doesn't have a style attribute it's a placeholder if ($timeline.hasAttribute('style')) { observeTimelineItems($timeline) } else { log('waiting for timeline') let startTime = Date.now() pageObservers.push( observeElement($timeline.parentElement, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {HTMLElement} */ $timeline) => { disconnectPageObserver('timeline parent') if (Date.now() > startTime) { log(`timeline appeared after ${Date.now() - startTime}ms`, $timeline) } observeTimelineItems($timeline) }) }) }, 'timeline parent') ) } // On some tabbed timeline pages, the first time a new tab is navigated to, // the element containing the timeline is replaced with a loading spinner. if (isTabbed && tabbedTimelineContainerSelector) { let $tabbedTimelineContainer = document.querySelector(tabbedTimelineContainerSelector) if ($tabbedTimelineContainer) { let waitingForNewTimeline = false pageObservers.push( observeElement($tabbedTimelineContainer, async (mutations) => { // This is going to fire twice on a new tab, as the spinner is added // then replaced with the new timeline element. if (!mutations.some(mutation => mutation.addedNodes.length > 0) || waitingForNewTimeline) return waitingForNewTimeline = true let $newTimeline = await getElement(timelineSelector, { name: 'new timeline', stopIf: pageIsNot(page), }) waitingForNewTimeline = false if (!$newTimeline) return log('tab changed') onTabChanged?.() observeTimelineItems($newTimeline) }, 'tabbed timeline container') ) } else { warn('tabbed timeline container not found', tabbedTimelineContainerSelector) } } } /** * @param {HTMLElement} $editorRoot * @param {{ * name?: string * observers: import("./types").Disconnectable[] * placeholder?: string * }} options */ function observeDesktopTweetEditorPlaceholder($editorRoot, { name = 'Tweet editor root (for placeholder)', observers, placeholder = '', }) { observers.push( observeElement($editorRoot, () => { if ($editorRoot.firstElementChild.classList.contains('public-DraftEditorPlaceholder-root')) { let $placeholder = $editorRoot.querySelector('.public-DraftEditorPlaceholder-inner') placeholder = $editorRoot.getAttribute('data-placeholder') || placeholder if ($placeholder && $placeholder.textContent != placeholder) { $placeholder.textContent = placeholder } } }, name) ) } /** * @param {string} page */ async function observeIndividualTweetTimeline(page) { let $timeline = await getElement(Selectors.TIMELINE, { name: 'initial individual tweet timeline', stopIf: pageIsNot(page), }) if ($timeline == null) return /** * @param {HTMLElement} $timeline */ function observeTimelineItems($timeline) { pageObservers.push( observeElement($timeline, () => onIndividualTweetTimelineChange($timeline, {observers: pageObservers}), 'individual tweet timeline') ) } // If the inital timeline doesn't have a style attribute it's a placeholder if ($timeline.hasAttribute('style')) { observeTimelineItems($timeline) } else { log('waiting for individual tweet timeline') let startTime = Date.now() pageObservers.push( observeElement($timeline.parentElement, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {HTMLElement} */ $timeline) => { disconnectPageObserver('individual tweet timeline parent') if (Date.now() > startTime) { log(`individual tweet timeline appeared after ${Date.now() - startTime}ms`, $timeline) } observeTimelineItems($timeline) }) }) }, 'individual tweet timeline parent') ) } } //#endregion //#region Tweak functions /** * Add an "Add muted word" menu item after the given link which takes you * straight to entering a new muted word (by clicking its way through all the * individual screens!). * @param {HTMLElement} $link * @param {string} linkSelector */ async function addAddMutedWordMenuItem($link, linkSelector) { log('adding "Add muted word" menu item') // Wait for the dropdown to appear on desktop if (desktop) { $link = await getElement(`#layers div[data-testid="Dropdown"] ${linkSelector}`, { name: 'rendered menu item', timeout: 100, }) if (!$link) return } let $addMutedWord = /** @type {HTMLElement} */ ($link.parentElement.cloneNode(true)) $addMutedWord.classList.add('tnt_menu_item') $addMutedWord.querySelector('a').href = PagePaths.ADD_MUTED_WORD $addMutedWord.querySelector('span').textContent = getString('ADD_MUTED_WORD') $addMutedWord.querySelector('svg').innerHTML = Svgs.MUTE $addMutedWord.addEventListener('click', (e) => { e.preventDefault() addMutedWord() }) $link.parentElement.insertAdjacentElement('beforebegin', $addMutedWord) } function addCaretMenuListenerForQuoteTweet($tweet) { let $caret = /** @type {HTMLElement} */ ($tweet.querySelector('[data-testid="caret"]')) if ($caret && !$caret.dataset.tweakNewTwitterListener) { $caret.addEventListener('click', () => { quotedTweet = getQuotedTweetDetails($tweet, {getText: true}) }) $caret.dataset.tweakNewTwitterListener = 'true' } } /** * @param {HTMLElement} $blockMenuItem */ async function addMuteQuotesMenuItems($blockMenuItem) { log('mutableQuoteTweets: adding "Mute this conversation" and "Turn off Quote Tweets" menu item') // Wait for the menu to render properly on desktop if (desktop) { $blockMenuItem = await getElement(`:scope > div > div > div > ${Selectors.BLOCK_MENU_ITEM}`, { context: $blockMenuItem.parentElement, name: 'rendered block menu item', timeout: 100, }) if (!$blockMenuItem) return } let $muteQuotes = /** @type {HTMLElement} */ ($blockMenuItem.previousElementSibling.cloneNode(true)) $muteQuotes.classList.add('tnt_menu_item') $muteQuotes.querySelector('span').textContent = getString('MUTE_THIS_CONVERSATION') $muteQuotes.addEventListener('click', (e) => { e.preventDefault() log('mutableQuoteTweets: muting quotes of a tweet', quotedTweet) config.mutedQuotes = config.mutedQuotes.concat(quotedTweet) storeConfigChanges({mutedQuotes: config.mutedQuotes}) processCurrentPage() // Dismiss the menu let $menuLayer = /** @type {HTMLElement} */ ($blockMenuItem.closest('[role="group"]')?.firstElementChild?.firstElementChild) if (!$menuLayer) { warn('mutableQuoteTweets: could not find menu layer to dismiss menu') } $menuLayer?.click() }) if (quotedTweet?.quotedBy) { let $toggleQuotes = /** @type {HTMLElement} */ ($blockMenuItem.previousElementSibling.cloneNode(true)) $toggleQuotes.classList.add('tnt_menu_item') $toggleQuotes.querySelector('span').textContent = getString(`TURN_OFF_QUOTE_TWEETS`) $toggleQuotes.querySelector('svg').innerHTML = Svgs.RETWEETS_OFF $toggleQuotes.addEventListener('click', (e) => { e.preventDefault() log('mutableQuoteTweets: toggling quotes from', quotedTweet.quotedBy) if (config.hideQuotesFrom.includes(quotedTweet.quotedBy)) { config.hideQuotesFrom = config.hideQuotesFrom.filter(user => user != quotedTweet.quotedBy) } else { config.hideQuotesFrom = config.hideQuotesFrom.concat(quotedTweet.quotedBy) } storeConfigChanges({hideQuotesFrom: config.hideQuotesFrom}) processCurrentPage() // Dismiss the menu let $menuLayer = /** @type {HTMLElement} */ ($blockMenuItem.closest('[role="group"]')?.firstElementChild?.firstElementChild) if (!$menuLayer) { warn('mutableQuoteTweets: could not find menu layer to dismiss menu') } $menuLayer?.click() }) $blockMenuItem.insertAdjacentElement('beforebegin', $toggleQuotes) } else { warn('mutableQuoteTweets: quotedBy not available when Tweet menu was opened') } $blockMenuItem.insertAdjacentElement('beforebegin', $muteQuotes) } async function addMutedWord() { if (!document.querySelector('a[href="/settings')) { let $settingsAndSupport = /** @type {HTMLElement} */ (document.querySelector('[data-testid="settingsAndSupport"]')) $settingsAndSupport?.click() } for (let path of [ '/settings', '/settings/privacy_and_safety', '/settings/mute_and_block', '/settings/muted_keywords', '/settings/add_muted_keyword', ]) { let $link = await getElement(`a[href="${path}"]`, {timeout: 500}) if (!$link) return $link.click() } let $input = await getElement('input[name="keyword"]') setTimeout(() => $input.focus(), 100) } /** * Add a "Turn on/off Retweets" menu item to a List's menu. * @param {HTMLElement} $switchMenuItem */ async function addToggleListRetweetsMenuItem($switchMenuItem) { log('adding "Turn on/off Retweets" menu item') // Wait for the menu to render properly on desktop if (desktop) { $switchMenuItem = await getElement(':scope > div > div > div > [role="menuitem"]', { context: $switchMenuItem.parentElement, name: 'rendered switch menu item', timeout: 100, }) if (!$switchMenuItem) return } let $toggleRetweets = /** @type {HTMLElement} */ ($switchMenuItem.cloneNode(true)) $toggleRetweets.classList.add('tnt_menu_item') $toggleRetweets.querySelector('span').textContent = getString(`TURN_${config.listRetweets == 'ignore' ? 'OFF' : 'ON'}_RETWEETS`) $toggleRetweets.querySelector('svg').innerHTML = config.listRetweets == 'ignore' ? Svgs.RETWEETS_OFF : Svgs.RETWEET // Remove subtitle if the cloned menu item has one $toggleRetweets.querySelector('div[dir] + div[dir]')?.remove() $toggleRetweets.addEventListener('click', (e) => { e.preventDefault() log('toggling list retweets') config.listRetweets = config.listRetweets == 'ignore' ? 'hide' : 'ignore' storeConfigChanges({listRetweets: config.listRetweets}) processCurrentPage() // Dismiss the menu let $menuLayer = /** @type {HTMLElement} */ ($switchMenuItem.closest('[role="group"]')?.firstElementChild?.firstElementChild) if (!$menuLayer) { log('could not find menu layer to dismiss menu') } $menuLayer?.click() }) $switchMenuItem.insertAdjacentElement('beforebegin', $toggleRetweets) } /** * Redirects away from the Home timeline if we're on it and it's been disabled. * @returns {boolean} `true` if redirected as a r###lt of this call */ function checkforDisabledHomeTimeline() { if (config.disableHomeTimeline && location.pathname == PagePaths.HOME) { log(`Home timeline disabled, redirecting to /${config.disabledHomeTimelineRedirect}`) let primaryNavSelector = desktop ? Selectors.PRIMARY_NAV_DESKTOP : Selectors.PRIMARY_NAV_MOBILE void (async () => { let $navLink = await getElement(`${primaryNavSelector} a[href="/${config.disabledHomeTimelineRedirect}"]`, { name: `${config.disabledHomeTimelineRedirect} nav link`, stopIf: () => location.pathname != PagePaths.HOME, }) if (!$navLink) return $navLink.click() })() return true } } //#region CSS const configureCss = (() => { let $style return function configureCss() { $style ??= addStyle('features') let cssRules = [` .tnt_font_family { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } `] let hideCssSelectors = ['.HiddenTweet', '.HiddenTweet + [role="separator"]'] let menuRole = `[role="${desktop ? 'menu' : 'dialog'}"]` // Theme colours for custom UI items cssRules.push(` body.Default { --border-color: rgb(239, 243, 244); --color: rgb(83, 100, 113); --color-emphasis: rgb(15, 20, 25); --hover-bg-color: rgb(247, 249, 249); } body.Dim { --border-color: rgb(56, 68, 77); --color: rgb(139, 152, 165); --color-emphasis: rgb(247, 249, 249); --hover-bg-color: rgb(30, 39, 50); } body.LightsOut { --border-color: rgb(47, 51, 54); --color: rgb(113, 118, 123); --color-emphasis: rgb(247, 249, 249); --hover-bg-color: rgb(22, 24, 28); } .tnt_menu_item:hover { background-color: var(--hover-bg-color) !important; } `) if (config.alwaysUseLatestTweets && config.hideForYouTimeline) { cssRules.push(` /* Prevent the For you tab container taking up space */ body.HomeTimeline nav.TimelineTabs div[role="tablist"] > div:first-child { flex-grow: 0; flex-shrink: 1; /* New layout has margin-right on tabs */ margin-right: 0; } /* Hide the For you tab link */ body.HomeTimeline nav.TimelineTabs div[role="tablist"] > div:first-child > a { display: none; } `) } if (config.disableTweetTextFormatting) { cssRules.push(` div[data-testid="tweetText"] span { font-style: normal; font-weight: normal; } `) } if (config.dropdownMenuFontWeight) { cssRules.push(` [data-testid="${desktop ? 'Dropdown' : 'sheetDialog'}"] [role="menuitem"] [dir] { font-weight: normal; } `) } if (config.hideBookmarkButton) { // Under timeline tweets hideCssSelectors.push( 'body:not(.Bookmarks) [data-testid="tweet"][tabindex="0"] [role="group"] > div:has(> button[data-testid$="ookmark"])', ) if (!config.showBookmarkButtonUnderFocusedTweets) { // Under the focused tweet hideCssSelectors.push( '[data-testid="tweet"][tabindex="-1"] [role="group"][id^="id__"] > div:has(> button[data-testid$="ookmark"])', ) } } if (config.hideListsNav) { hideCssSelectors.push(`${menuRole} a[href$="/lists"]`) } if (config.hideBookmarksNav) { hideCssSelectors.push(`${menuRole} a[href$="/bookmarks"]`) } if (config.hideCommunitiesNav) { hideCssSelectors.push(`${menuRole} a[href$="/communities"]`) } if (config.hideShareTweetButton) { hideCssSelectors.push( // Under timeline tweets `[data-testid="tweet"][tabindex="0"] [role="group"] > div[style]:not(${TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR})`, // Under the focused tweet `[data-testid="tweet"][tabindex="-1"] [role="group"] > div[style]:not(${TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR})`, ) } if (config.hid###bscriptions) { hideCssSelectors.push( // Subscribe buttons in profile (multiple locations) 'body.Profile [role="button"][style*="border-color: rgb(201, 54, 204)"]', // Subscriptions count in profile 'body.Profile a[href$="/creator-subscriptions/subscriptions"]', // Subs tab in profile 'body.Profile .SubsTab', // Subscribe button in focused tweet '[data-testid="tweet"][tabindex="-1"] [data-testid$="-subscribe"]', // "Subscribe to" dropdown item (desktop) '[data-testid="Dropdown"] > [data-testid="subscribe"]', // "Subscribe to" menu item (mobile) '[data-testid="sheetDialog"] > [data-testid="subscribe"]', // "Subscriber" indicator in replies from subscribers '[data-testid="tweet"] [data-testid="icon-subscriber"]', // Monetization and Subscriptions items in Settings 'body.Settings a[href="/settings/monetization"]', 'body.Settings a[href="/settings/manage_subscriptions"]', // Subscriptions tab link in Following/Follows `body.ProfileFollows.Subscriptions ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:last-child > a`, ) // Subscriptions tab in Following/Follows cssRules.push(` body.ProfileFollows.Subscriptions ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:last-child { flex: 0; /* New layout has margin-right on tabs */ margin-right: 0; } `) } if (config.hideMetrics) { configureHideMetricsCss(cssRules, hideCssSelectors) } if (config.hideMoreTweets) { hideCssSelectors.push('.SuggestedContent') } if (config.hideCommunitiesNav) { hideCssSelectors.push(`${menuRole} a[href$="/communities"]`) } if (config.hideGrokNav) { hideCssSelectors.push( // In menus `${menuRole} a[href$="/i/grok"]`, // Grok Actions button `button[aria-label="${getString('GROK_ACTIONS')}"]`, // "Generate image" button in the Tweet editor 'button[data-testid="grokImgGen"]', // Any Grok buttons we manually tag '.GrokButton', // Grok suggested prompts in Tweets '[data-testid="tweet"] [data-testid^="followups_"]', '[data-testid="tweet"] [data-testid^="followups_"] + nav', // Profile Summary button `button[aria-label="${getString('PROFILE_SUMMARY')}"]`, // Grok summary at the top of search r###lts 'body.Search [data-testid="primaryColumn"] > div > div:has(> [data-testid="followups_search"])', ) } if (config.hideMonetizationNav) { hideCssSelectors.push(`${menuRole} a[href$="/i/monetization"]`) } if (config.hideAdsNav) { hideCssSelectors.push(`${menuRole} a:is([href*="ads.twitter.com"], [href*="ads.x.com"])`) } if (config.hideJobsNav) { hideCssSelectors.push( // Jobs navigation item `${menuRole} a[href="/jobs"]`, // Jobs section in profiles '.Profile [data-testid="jobs"]', ) } if (config.hideTweetAnalyticsLinks) { hideCssSelectors.push('.AnalyticsButton') } if (config.hideTwitterBlueUpsells) { hideCssSelectors.push( // Manually-tagged upsells '.PremiumUpsell', // Premium/Verified menu items `${menuRole} a:is([href^="/i/premium"], [href^="/i/verified"])`, // In new More dialog `${Selectors.MORE_DIALOG} a:is([href^="/i/premium"], [href^="/i/verified"])`, // Analytics menu item `${menuRole} a[href="/i/account_analytics"]`, // "Highlight on your profile" on your tweets '[role="menuitem"][data-testid="highlightUpsell"]', // "Edit" upsell on recent tweets '[role="menuitem"][data-testid="editWithPremium"]', // Premium item in Settings 'body.Settings a[href^="/i/premium"]', // Misc upsells in your own profile `.OwnProfile ${Selectors.PRIMARY_COLUMN} a[href^="/i/premium"]`, // Unlock Analytics button in your own profile '.OwnProfile [data-testid="analytics-preview"]', // Button in Communities header `body.Communities ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} a:is([href^="/i/premium"], [href^="/i/verified"])`, ) // Hide Highlights and Articles tabs in your own profile if you don't have Premium let profileTabsList = `body.OwnProfile:not(.PremiumProfile) ${Selectors.PRIMARY_COLUMN} nav div[role="tablist"]` let upsellTabLinks = 'a:is([href$="/highlights"], [href$="/articles"], [href$="/highlights?mx=1"], [href$="/articles?mx=1"])' cssRules.push(` ${profileTabsList} > div:has(> ${upsellTabLinks}) { flex: 0; /* New layout has margin-right on tabs */ margin-right: 0; } ${profileTabsList} > div > ${upsellTabLinks} { display: none; } `) // Hide upsell on the Likes tab in your own profile cssRules.push(` body.OwnProfile ${Selectors.PRIMARY_COLUMN} nav + div:has(a[href^="/i/premium"]) { display: none; } `) } if (config.hideVerifiedNotificationsTab) { cssRules.push(` body.Notifications ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:nth-child(2), body.ProfileFollows ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:nth-child(1) { flex: 0; /* New layout has margin-right on tabs */ margin-right: 0; } body.Notifications ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:nth-child(2) > a, body.ProfileFollows ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:nth-child(1) > a { display: none; } `) } if (config.hideViews) { hideCssSelectors.push( // "Views" under the focused tweet '[data-testid="tweet"][tabindex="-1"] div[dir] + div[aria-hidden="true"]:nth-child(2):nth-last-child(2)', '[data-testid="tweet"][tabindex="-1"] div[dir] + div[aria-hidden="true"]:nth-child(2):nth-last-child(2) + div[dir]:last-child' ) } if (config.hideWhoToFollowEtc) { hideCssSelectors.push(`body.Profile ${Selectors.PRIMARY_COLUMN} aside[role="complementary"]`) } if (config.reducedInteractionMode) { hideCssSelectors.push( '[data-testid="tweet"] [role="group"]', 'body.Tweet [data-testid="tweet"] + div > div [role="group"]', ) } if (config.restoreLinkHeadlines) { hideCssSelectors.push( // Existing headline overlaid on the card '.tnt_overlay_headline', // From <domain> link after the card 'div[data-testid="card.wrapper"] + a', ) } else { hideCssSelectors.push('.tnt_link_headline') } if (config.restoreQuoteTweetsLink || config.restoreOtherInteractionLinks) { cssRules.push(` #tntInteractionLinks a { text-decoration: none; color: var(--color); } #tntInteractionLinks a:hover span:last-child { text-decoration: underline; } #tntQuoteTweetCount, #tntRetweetCount, #tntLikeCount { margin-right: 2px; font-weight: 700; color: var(--color-emphasis); } /* Replaces the "View post engagements" link under your own tweets */ .AnalyticsButton { display: none; } `) } else { hideCssSelectors.push('#tntInteractionLinks') } if (!config.restoreQuoteTweetsLink) { hideCssSelectors.push('#tntQuoteTweetsLink') } if (!config.restoreOtherInteractionLinks) { hideCssSelectors.push('#tntRetweetsLink', '#tntLikesLink') } if (config.tweakQuoteTweetsPage) { // Hide the quoted tweet, which is repeated in every quote tweet hideCssSelectors.push('body.QuoteTweets [data-testid="tweet"] [aria-labelledby] > div:last-child') } if (config.twitterBlueChecks == 'hide') { hideCssSelectors.push('.tnt_blue_check') } if (config.twitterBlueChecks == 'replace') { cssRules.push(` :is(${Selectors.VERIFIED_TICK}, svg[data-testid="verificationBadge"]).tnt_blue_check path { d: path("${Svgs.BLUE_LOGO_PATH}"); } `) } if (shouldShowSeparatedTweetsTab()) { if (hasNewLayout()) { // The new layout only has colour to distinguish the active tab cssRules.push(` body:not(.SeparatedTweets) #tnt_separated_tweets_tab > a > div > div, body.HomeTimeline.SeparatedTweets ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:not(#tnt_separated_tweets_tab) > a > div > div { color: var(--color) !important; } body.SeparatedTweets #tnt_separated_tweets_tab > a > div > div { color: var(--color-emphasis) !important; } body.Desktop #tnt_separated_tweets_tab:hover > a > div > div { color: var(--color-emphasis) !important; } `) } else { cssRules.push(` body.Default { --tab-hover: rgba(15, 20, 25, 0.1); } body.Dim { --tab-hover: rgba(247, 249, 249, 0.1); } body.LightsOut { --tab-hover: rgba(231, 233, 234, 0.1); } body.Desktop #tnt_separated_tweets_tab:hover, body.Mobile:not(.SeparatedTweets) #tnt_separated_tweets_tab:hover, body.Mobile #tnt_separated_tweets_tab:active { background-color: var(--tab-hover); } body:not(.SeparatedTweets) #tnt_separated_tweets_tab > a > div > div, body.HomeTimeline.SeparatedTweets ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:not(#tnt_separated_tweets_tab) > a > div > div { font-weight: normal !important; color: var(--color) !important; } body.SeparatedTweets #tnt_separated_tweets_tab > a > div > div { font-weight: bold; color: var(--color-emphasis); !important; } body:not(.SeparatedTweets) #tnt_separated_tweets_tab > a > div > div > div, body.HomeTimeline.SeparatedTweets ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:not(#tnt_separated_tweets_tab) > a > div > div > div { height: 0 !important; } body.SeparatedTweets #tnt_separated_tweets_tab > a > div > div > div { height: 4px !important; min-width: 56px; width: 100%; position: absolute; bottom: 0; border-radius: 9999px; } `) } } if (hasNewLayout() && config.tweakNewLayout) { cssRules.push(` /* Make the image button first in the Tweet editor toolbar again */ [data-testid="toolBar"] [role="tablist"] > [role="presentation"] { order: 1; } [data-testid="toolBar"] [role="tablist"] > [role="presentation"]:has(input[data-testid="fileInput"]) { order: 0; } `) if (config.replaceLogo) { cssRules.push(` /* Add theme colour back to Tweet editor toolbar buttons */ [data-testid="toolBar"] [role="tablist"] > [role="presentation"] svg { fill: var(--theme-color); } `) } } //#region Desktop-only if (desktop) { if (hasNewLayout() && config.tweakNewLayout) { cssRules.push(` /* Realign nav items to the top */ header[role="banner"] > div > div > div { justify-content: flex-start; } /* Restore size and constrast of main nav icons and More button */ ${Selectors.PRIMARY_NAV_DESKTOP} > :is(a, button) svg { width: 1.75rem !important; height: 1.75rem !important; fill: var(--color-emphasis) !important; } /* Restore contrast of main nav text when expanded */ ${Selectors.PRIMARY_NAV_DESKTOP} > :is(a, button) div[dir]:not([aria-live]) { color: var(--color-emphasis) !important; } /* Give other nav button icons more contrast too */ header[role="banner"] button svg { fill: var(--color-emphasis) !important; } /* Make the Tweet button larger */ [data-testid="SideNav_NewTweet_Button"] { min-width: 49px; min-height: 49px; } /* Move the account switcher back to the bottom */ header[role="banner"] > div > div > div > div:last-child { flex: 1; justify-content: space-between; } /* Restore primary column borders */ header[role="banner"] > div > div > div { border-right: 1px solid var(--border-color); } ${Selectors.PRIMARY_COLUMN} { border-right: 1px solid var(--border-color); } /* Left-align main contents and stop it taking up all available space */ main { align-items: flex-start !important; flex-grow: 0 !important; } /* Remove the gap between main contents and sidebar */ main > div > div > div { justify-content: normal !important; } /* Restore the sidebar to its old width */ ${Selectors.SIDEBAR}, ${Selectors.SIDEBAR} > div > div, body.HomeTimeline ${Selectors.SIDEBAR_WRAPPERS} > div > div:first-child, ${Selectors.SIDEBAR_WRAPPERS} > div:first-child { width: 350px !important; } /* Center content */ div[data-at-shortcutkeys] { justify-content: center; } `) if (config.replaceLogo) { // TODO Manually patch Tweet button SVG in Safari cssRules.push(` /* Restore theme colour in nav item pips */ ${Selectors.PRIMARY_NAV_DESKTOP} > :is(a[href^="/notifications"], a[href="/messages"]) div[aria-live], ${Selectors.MORE_DIALOG} :is(a[href^="/notifications"], a[href="/messages"]) div[aria-live], /* Restore theme colour in profile switcher other accounts have notifications pip */ button[data-testid="SideNav_AccountSwitcher_Button"] > div > div[aria-label], /* Restore theme colour in account switcher notifications pips */ [data-testid="HoverCard"] button[data-testid="UserCell"] div[aria-live] { background-color: var(--theme-color); } /* Replace the plus icon in the Tweet button with the feather */ [data-testid="SideNav_NewTweet_Button"] path[d="${Svgs.PLUS_PATH}"] { d: path("${Svgs.TWITTER_FEATHER_PLUS_PATH}"); } `) } } if (hasNewLayout() && config.hideToggleNavigation) { hideCssSelectors.push('header[role="banner"] > div > div > div > div:first-child > button') } if (config.navDensity == 'comfortable' || config.navDensity == 'compact') { cssRules.push(` header nav > a, header nav > div[data-testid="AppTabBar_More_Menu"] { padding-top: 0 !important; padding-bottom: 0 !important; } `) } if (config.navDensity == 'compact') { cssRules.push(` header nav > a > div, header nav > div[data-testid="AppTabBar_More_Menu"] > div { padding-top: 6px !important; padding-bottom: 6px !important; } `) } if (config.hideSeeNewTweets) { hideCssSelectors.push(`body.HomeTimeline ${Selectors.PRIMARY_COLUMN} > div > div:first-child > div[style^="transform"]`) } if (config.hideTimelineTweetBox) { hideCssSelectors.push(`body.HomeTimeline ${Selectors.PRIMARY_COLUMN} .TweetBox`) } if (config.disableHomeTimeline) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV_DESKTOP} a[href="/home"]`) } if (config.hideNotifications != 'ignore') { // Hide notification badges and indicators hideCssSelectors.push( // Notifications & Messages in primary nav `${Selectors.PRIMARY_NAV_DESKTOP} > :is(a[href^="/notifications"], a[href="/messages"]) div[aria-live]`, // Notifications & Messages in the More dialog in the new layout `${Selectors.MORE_DIALOG} :is(a[href^="/notifications"], a[href="/messages"]) div[aria-live]`, // Account switcher 'button[data-testid="SideNav_AccountSwitcher_Button"] > div > div[aria-label]', // Account switcher accounts '[data-testid="HoverCard"] button[data-testid="UserCell"] div[aria-live]', // Messages drawer title '[data-testid="DMDrawerHeader"] h2 svg[role="img"]' ) if (config.hideNotifications == 'hide') { hideCssSelectors.push( // Nav item `${Selectors.PRIMARY_NAV_DESKTOP} a[href^="/notifications"]`, // More dialog item `${Selectors.MORE_DIALOG} a[href^="/notifications"]`, ) } } if (config.fullWidthContent) { cssRules.push(` /* Use full width when the sidebar is visible */ body.Sidebar${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN}, body.Sidebar${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} > div:first-child > div:last-child { max-width: 990px; } /* Make the "What's happening" input keep its original width */ body.HomeTimeline ${Selectors.PRIMARY_COLUMN} > div:first-child > div:nth-of-type(3) div[role="progressbar"] + div { max-width: 598px; } /* Use full width when the sidebar is not visible */ body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} header[role="banner"] { flex-grow: 0; } body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} main[role="main"] > div { width: 100%; } body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} { max-width: unset; width: 100%; } body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} > div:first-child > div:first-child div, body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} > div:first-child > div:last-child { max-width: unset; } `) if (!config.fullWidthMedia) { // Make media & cards keep their original width cssRules.push(` body${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} ${Selectors.TWEET} > div > div > div:nth-of-type(2) > div:nth-of-type(2) > div[id][aria-labelledby]:not(:empty) { max-width: 504px; } `) } // Hide the sidebar when present hideCssSelectors.push(`body.Sidebar${FULL_WIDTH_BODY_PSEUDO} ${Selectors.SIDEBAR}`) } if (config.hideAccountSwitcher) { cssRules.push(` header[role="banner"] > div > div > div > div:last-child { flex-shrink: 1 !important; align-items: flex-end !important; } `) hideCssSelectors.push( '[data-testid="SideNav_AccountSwitcher_Button"] > div:first-child:not(:only-child)', '[data-testid="SideNav_AccountSwitcher_Button"] > div:first-child + div', ) } if (config.hideExplorePageContents) { hideCssSelectors.push( // Tabs `body.Explore ${Selectors.DESKTOP_TIMELINE_HEADER} nav`, // Content `body.Explore ${Selectors.TIMELINE}`, ) } if (config.hideAdsNav) { // In new More dialog hideCssSelectors.push(`${Selectors.MORE_DIALOG} a:is([href*="ads.twitter.com"], [href*="ads.x.com"])`) } if (config.hideComposeTweet) { hideCssSelectors.push('[data-testid="SideNav_NewTweet_Button"]') } if (config.hideGrokNav) { hideCssSelectors.push( `${Selectors.PRIMARY_NAV_DESKTOP} a[href$="/i/grok"]`, // In new More dialog `${Selectors.MORE_DIALOG} a[href$="/i/grok"]`, // Grok drawer 'div[data-testid="GrokDrawer"]', ) } if (config.hideJobsNav) { hideCssSelectors.push( `${Selectors.PRIMARY_NAV_DESKTOP} a[href="/jobs"]`, // In new More dialog `${Selectors.MORE_DIALOG} a[href="/jobs"]`, ) } if (config.hideListsNav) { hideCssSelectors.push( `${Selectors.PRIMARY_NAV_DESKTOP} a[href$="/lists"]`, // In new More dialog `${Selectors.MORE_DIALOG} a[href$="/lists"]`, ) } if (config.hideMonetizationNav) { // In new More dialog hideCssSelectors.push(`${Selectors.MORE_DIALOG} a[href$="/i/monetization"]`) } if (config.hideProNav) { hideCssSelectors.push(`${menuRole} a:is([href*="pro.twitter.com"], [href*="pro.x.com"])`) } if (config.hideSpacesNav) { hideCssSelectors.push( `${menuRole} a[href="/i/spaces/start"]`, // In new More dialog `${Selectors.MORE_DIALOG} a[href="/i/spaces/start"]`, ) } if (config.hideTwitterBlueUpsells) { hideCssSelectors.push( // Nav items `${Selectors.PRIMARY_NAV_DESKTOP} a:is([href^="/i/premium"], [href^="/i/verified"])`, // Search sidebar Radar upsell `body.Search ${Selectors.SIDEBAR_WRAPPERS} > div:first-child:has(a[href="/i/radar"])`, `body.Search ${Selectors.SIDEBAR_WRAPPERS} > div:first-child:has(a[href="/i/radar"]) + div:empty`, ) } if (config.hideSidebarContent) { // Only show the first sidebar item by default // Re-show subsequent non-algorithmic sections on specific pages cssRules.push(` body.HomeTimeline ${Selectors.SIDEBAR_WRAPPERS} > div > div:not(:first-of-type) { display: none; } ${Selectors.SIDEBAR_WRAPPERS} > div:not(:first-of-type) { display: none; } body.Search ${Selectors.SIDEBAR_WRAPPERS} > div:nth-of-type(2) { display: block; } /* Radar upsell in Search uses the first item and adds a second one for spacing */ body.Search ${Selectors.SIDEBAR_WRAPPERS}:has(a[href="/i/radar"]) > div:first-of-type, body.Search ${Selectors.SIDEBAR_WRAPPERS}:has(a[href="/i/radar"]) > div:nth-of-type(2):empty { display: none; } body.Search ${Selectors.SIDEBAR_WRAPPERS}:has(a[href="/i/radar"]) > div:nth-of-type(3), body.Search ${Selectors.SIDEBAR_WRAPPERS}:has(a[href="/i/radar"]) > div:nth-of-type(4) { display: block; } `) if (config.showRelevantPeople) { cssRules.push(` body.Tweet ${Selectors.SIDEBAR_WRAPPERS} > div:is(:nth-of-type(2), :nth-of-type(3)) { display: block; } `) } hideCssSelectors.push(`body.HideSidebar ${Selectors.SIDEBAR}`) } else if (config.hideTwitterBlueUpsells) { // Hide "Subscribe to premium" individually hideCssSelectors.push( `body.HomeTimeline ${Selectors.SIDEBAR_WRAPPERS} > div:nth-of-type(3)` ) } if (config.hideShareTweetButton) { hideCssSelectors.push( // In media modal `[aria-modal="true"] div > div:first-of-type [role="group"] > div[style]:not([role]):not(${TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR})`, ) } if (config.hideExploreNav) { // When configured, hide Explore only when the sidebar is showing, or // when on a page full-width content is enabled on. let bodySelector = `${config.hideExploreNavWithSidebar ? `body.Sidebar${config.fullWidthContent ? `:not(${FULL_WIDTH_BODY_PSEUDO})` : ''} ` : ''}` hideCssSelectors.push( `${bodySelector}${Selectors.PRIMARY_NAV_DESKTOP} a[href="/explore"]`, // In new More dialog `${Selectors.MORE_DIALOG} a[href="/explore"]`, ) } if (config.hideBookmarksNav) { hideCssSelectors.push( `${Selectors.PRIMARY_NAV_DESKTOP} a[href="/i/bookmarks"]`, // In new More dialog `${Selectors.MORE_DIALOG} a[href="/i/bookmarks"]`, ) } if (config.hideCommunitiesNav) { hideCssSelectors.push( `${Selectors.PRIMARY_NAV_DESKTOP} a[href$="/communities"]`, // In new More dialog `${Selectors.MORE_DIALOG} a[href$="/communities"]`, ) } if (config.hideMessagesDrawer) { cssRules.push(`div[data-testid="DMDrawer"] { visibility: hidden; }`) } if (config.hideViews) { hideCssSelectors.push( // Under timeline tweets '[data-testid="tweet"][tabindex="0"] [role="group"] > div:has(> a[href$="/analytics"])', // In media modal '[aria-modal="true"] > div > div:first-of-type [role="group"] > div:has(> a[href$="/analytics"])', ) } if (config.retweets != 'separate' && config.quoteTweets != 'separate') { hideCssSelectors.push('#tnt_separated_tweets_tab') } } //#endregion //#region Mobile only if (mobile) { if (hasNewLayout() && config.tweakNewLayout) { cssRules.push(` /* Remove new padding from profile details and the tab bar (this has to be accidental) */ body.Profile ${Selectors.PRIMARY_COLUMN} > div > div > div > div > div > div > div > div { padding-left: 0; padding-right: 0; } `) if (config.replaceLogo) { cssRules.push(` /* Restore theme colour in nav item pips */ ${Selectors.PRIMARY_NAV_MOBILE} > :is(a[href^="/notifications"], a[href="/messages"]) div[aria-label], /* Restore theme colour in profile button other accounts have notifications pip */ button[data-testid="DashButton_ProfileIcon_Link"] div[aria-label], /* Restore theme colour in account switcher notifications pips */ [role="dialog"] [data-testid^="UserAvatar-Container"] div[dir] { background-color: var(--theme-color); } `) } } if (config.disableHomeTimeline) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href="/home"]`) } if (config.hideComposeTweet) { hideCssSelectors.push('[data-testid="FloatingActionButtons_Tweet_Button"]') } if (config.hideNotifications != 'ignore') { // Hide notification badges and indicators hideCssSelectors.push( // Notifications & Messages in primary nav `${Selectors.PRIMARY_NAV_MOBILE} > :is(a[href^="/notifications"], a[href="/messages"]) div[aria-label]`, // Account switcher `button[data-testid="DashButton_ProfileIcon_Link"] div[aria-label]`, // Account switcher accounts '[role="dialog"] [data-testid^="UserAvatar-Container"] div[dir]', ) if (config.hideNotifications == 'hide') { hideCssSelectors.push( // Nav item `${Selectors.PRIMARY_NAV_MOBILE} a[href^="/notifications"]` ) } } if (config.hideSeeNewTweets) { hideCssSelectors.push(`body.HomeTimeline ${Selectors.MOBILE_TIMELINE_HEADER} ~ div[style^="transform"]:last-child`) } if (config.hideExplorePageContents) { // Hide explore page contents so we don't get a brief flash of them // before automatically switching the page to search mode. hideCssSelectors.push( // Tabs `body.Explore ${Selectors.MOBILE_TIMELINE_HEADER} > div > div:nth-of-type(2)`, // Content `body.Explore ${Selectors.TIMELINE}`, ) } if (config.hideGrokNav) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href="/i/grok"]`) } if (config.hideCommunitiesNav) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href$="/communities"]`) } if (config.hideMessagesBottomNavItem) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href="/messages"]`) } if (config.hideJobsNav) { hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href="/jobs"]`) } if (config.hideTwitterBlueUpsells) { hideCssSelectors.push( `${Selectors.PRIMARY_NAV_MOBILE} a[href^="/i/premium"]`, `${Selectors.MOBILE_TIMELINE_HEADER} a[href^="/i/premium"]`, ) } if (config.hideShareTweetButton) { hideCssSelectors.push( // In media viewer and media modal `body:is(.MediaViewer, .MobileMedia) [role="group"] > div[style]:not(${TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR})`, ) } if (config.hideViews) { hideCssSelectors.push( // Under timeline tweets '[data-testid="tweet"][tabindex="0"] [role="group"] > div:has(> a[href$="/analytics"])', // In media viewer and media modal 'body:is(.MediaViewer, .MobileMedia) [role="group"] > div:has(> a[href$="/analytics"])', ) } //#endregion } if (hideCssSelectors.length > 0) { cssRules.push(` ${hideCssSelectors.join(',\n')} { display: none !important; } `) } $style.textContent = cssRules.map(dedent).join('\n') } })() function configureFont() { if (!fontFamilyRule) { warn('no fontFamilyRule found for configureFont to use') return } if (config.dontUseChirpFont) { if (fontFamilyRule.style.fontFamily.includes('TwitterChirp')) { fontFamilyRule.style.fontFamily = fontFamilyRule.style.fontFamily.replace(/"?TwitterChirp"?, ?/, '') log('disabled Chirp font') } } else if (!fontFamilyRule.style.fontFamily.includes('TwitterChirp')) { fontFamilyRule.style.fontFamily = `"TwitterChirp", ${fontFamilyRule.style.fontFamily}` log(`enabled Chirp font`) } } /** * @param {string[]} cssRules * @param {string[]} hideCssSelectors */ function configureHideMetricsCss(cssRules, hideCssSelectors) { if (config.hideFollowingMetrics) { // User profile hover card and page metrics hideCssSelectors.push( ':is(#layers, body.Profile) a:is([href$="/following"], [href$="/verified_followers"]) > span:first-child' ) // Fix display of whitespace after hidden metrics cssRules.push( ':is(#layers, body.Profile) a:is([href$="/following"], [href$="/verified_followers"]) { white-space: pre-line; }' ) } if (config.hideTotalTweetsMetrics) { // Metrics under username header on profile pages hideCssSelectors.push(` body.Profile ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} > div > div:first-of-type h2 + div[dir] `) } let timelineMetricSelectors = [ config.hideReplyMetrics && '[data-testid="reply"]', config.hideRetweetMetrics && '[data-testid$="retweet"]', config.hideLikeMetrics && '[data-testid$="like"]', config.hideBookmarkMetrics && '[data-testid$="bookmark"], [data-testid$="removeBookmark"]', ].filter(Boolean).join(', ') if (timelineMetricSelectors) { cssRules.push( `[role="group"] button:is(${timelineMetricSelectors}) span { visibility: hidden; }` ) } if (config.hideQuoteTweetMetrics) { hideCssSelectors.push('#tntQuoteTweetCount') } if (config.hideRetweetMetrics) { hideCssSelectors.push('#tntRetweetCount') } if (config.hideLikeMetrics) { hideCssSelectors.push('#tntLikeCount') } } const configureCustomCss = (() => { let $style return function configureCustomCss() { if (config.customCss) { $style ??= addStyle('custom') $style.textContent = config.customCss } else { $style?.remove() } } })() /** * CSS which depends on anything we need to get from the page. */ const configureDynamicCss = (() => { let $style return function configureDynamicCss() { $style ??= addStyle('dynamic') let cssRules = [] if (fontSize != null && config.navBaseFontSize) { cssRules.push(` ${Selectors.PRIMARY_NAV_DESKTOP} div[dir] span { font-size: ${fontSize}; font-weight: normal; } ${Selectors.PRIMARY_NAV_DESKTOP} div[dir] { margin-top: -4px; } `) } if (filterBlurRule != null && config.unblurSensitiveContent) { cssRules.push(` ${filterBlurRule.selectorText} { filter: none !important; } ${filterBlurRule.selectorText} + div { display: none !important; } `) } $style.textContent = cssRules.map(dedent).join('\n') } })() //#endregion /** * Configures – or re-configures – the separated tweets timeline title. * * If we're currently on the separated tweets timeline and… * - …its title has changed, the page title will be changed to "navigate" to it. * - …the separated tweets timeline is no longer needed, we'll change the page * title to "navigate" back to the Home timeline. * * @returns {boolean} `true` if "navigation" was triggered by this call */ function configureSeparatedTweetsTimelineTitle() { let wasOnSeparatedTweetsTimeline = isOnSeparatedTweetsTimeline() let previousTitle = separatedTweetsTimelineTitle if (config.retweets == 'separate' && config.quoteTweets == 'separate') { separatedTweetsTimelineTitle = getString(config.replaceLogo ? 'SHARED_TWEETS' : 'SHARED') } else if (config.retweets == 'separate') { separatedTweetsTimelineTitle = getString(config.replaceLogo ? 'RETWEETS' : 'REPOSTS') } else if (config.quoteTweets == 'separate') { separatedTweetsTimelineTitle = getString(config.replaceLogo ? 'QUOTE_TWEETS' : 'QUOTES') } else { separatedTweetsTimelineTitle = null } let titleChanged = previousTitle != separatedTweetsTimelineTitle if (wasOnSeparatedTweetsTimeline) { if (separatedTweetsTimelineTitle == null) { log('moving from separated tweets timeline to Home timeline after config change') setTitle(getString('HOME')) return true } if (titleChanged) { log('applying new separated tweets timeline title after config change') setTitle(separatedTweetsTimelineTitle) return true } } else { if (titleChanged && previousTitle != null && lastHomeTimelineTitle == previousTitle) { log('updating lastHomeTimelineTitle with new separated tweets timeline title') lastHomeTimelineTitle = separatedTweetsTimelineTitle } } } const configureThemeCss = (() => { let $style return function configureThemeCss() { $style ??= addStyle('theme') let cssRules = [] if (themeColor != null) { cssRules.push(` body { --theme-color: ${themeColor}; } `) } if (debug) { cssRules.push(` [data-item-type]::after { position: absolute; top: 0; ${ltr ? 'right': 'left'}: 50px; content: attr(data-item-type); font-family: ${fontFamilyRule?.style.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial'}; background-color: rgb(242, 29, 29); color: white; font-size: 11px; font-weight: bold; padding: 4px 6px; border-bottom-left-radius: 1em; border-bottom-right-radius: 1em; } `) } // Active tab colour for custom tabs if (themeColor != null && shouldShowSeparatedTweetsTab()) { cssRules.push(` body.SeparatedTweets #tnt_separated_tweets_tab > a > div > div > div { background-color: ${themeColor} !important; } `) } if (config.replaceLogo) { cssRules.push(` ${Selectors.X_LOGO_PATH}, ${Selectors.X_DARUMA_LOGO_PATH} { fill: ${THEME_BLUE}; d: path("${Svgs.TWITTER_LOGO_PATH}"); } .tnt_logo { fill: ${THEME_BLUE}; } svg path[d="${Svgs.X_HOME_ACTIVE_PATH}"] { d: path("${Svgs.TWITTER_HOME_ACTIVE_PATH}"); } svg path[d="${Svgs.X_HOME_INACTIVE_PATH}"] { d: path("${Svgs.TWITTER_HOME_INACTIVE_PATH}"); } `) if (desktop) { // Revert the Tweet buttons being made monochrome cssRules.push(` [data-testid="SideNav_NewTweet_Button"], [data-testid="tweetButtonInline"], [data-testid="tweetButton"] { background-color: ${themeColor} !important; } [data-testid="SideNav_NewTweet_Button"]:hover, [data-testid="tweetButtonInline"]:hover:not(:disabled), [data-testid="tweetButton"]:hover:not(:disabled) { background-color: ${themeColor.replace(')', ', 80%)')} !important; } body:is(.Dim, .LightsOut):not(.HighContrast) [data-testid="SideNav_NewTweet_Button"] > div, body:is(.Dim, .LightsOut):not(.HighContrast) [data-testid="tweetButtonInline"] > div, body:is(.Dim, .LightsOut):not(.HighContrast) [data-testid="tweetButton"] > div, body:is(.Dim, .LightsOut):not(.HighContrast) [data-testid="SideNav_NewTweet_Button"] > div > svg { color: rgb(255, 255, 255) !important; } `) } } if (config.uninvertFollowButtons) { // Shared styles for Following and Follow buttons cssRules.push(` [role="button"][data-testid$="-unfollow"]:not(:hover) { border-color: rgba(0, 0, 0, 0) !important; } [role="button"][data-testid$="-follow"] { background-color: rgba(0, 0, 0, 0) !important; } `) if (config.followButtonStyle == 'monochrome' || themeColor == null) { cssRules.push(` /* Following button */ body.Default [role="button"][data-testid$="-unfollow"]:not(:hover) { background-color: rgb(15, 20, 25) !important; } body.Default [role="button"][data-testid$="-unfollow"]:not(:hover) > :is(div, span) { color: rgb(255, 255, 255) !important; } body:is(.Dim, .LightsOut) [role="button"][data-testid$="-unfollow"]:not(:hover) { background-color: rgb(255, 255, 255) !important; } body:is(.Dim, .LightsOut) [role="button"][data-testid$="-unfollow"]:not(:hover) > :is(div, span) { color: rgb(15, 20, 25) !important; } /* Follow button */ body.Default [role="button"][data-testid$="-follow"] { border-color: rgb(207, 217, 222) !important; } body:is(.Dim, .LightsOut) [role="button"][data-testid$="-follow"] { border-color: rgb(83, 100, 113) !important; } body.Default [role="button"][data-testid$="-follow"] > :is(div, span) { color: rgb(15, 20, 25) !important; } body:is(.Dim, .LightsOut) [role="button"][data-testid$="-follow"] > :is(div, span) { color: rgb(255, 255, 255) !important; } body.Default [role="button"][data-testid$="-follow"]:hover { background-color: rgba(15, 20, 25, 0.1) !important; } body:is(.Dim, .LightsOut) [role="button"][data-testid$="-follow"]:hover { background-color: rgba(255, 255, 255, 0.1) !important; } `) } if (config.followButtonStyle == 'themed' && themeColor != null) { cssRules.push(` /* Following button */ [role="button"][data-testid$="-unfollow"]:not(:hover) { background-color: var(--theme-color) !important; } [role="button"][data-testid$="-unfollow"]:not(:hover) > :is(div, span) { color: rgb(255, 255, 255) !important; } /* Follow button */ [role="button"][data-testid$="-follow"] { border-color: var(--theme-color) !important; } [role="button"][data-testid$="-follow"] > :is(div, span) { color: var(--theme-color) !important; } [role="button"][data-testid$="-follow"]:hover { background-color: var(--theme-color) !important; } [role="button"][data-testid$="-follow"]:hover > :is(div, span) { color: rgb(255, 255, 255) !important; } `) } if (mobile) { cssRules.push(` body.MediaViewer [role="button"][data-testid$="follow"] { border: none !important; background: transparent !important; } body.MediaViewer [role="button"][data-testid$="follow"] > div { color: var(--theme-color) !important; } `) } } $style.textContent = cssRules.map(dedent).join('\n') } })() function getColorScheme() { return { 'rgb(255, 255, 255)': 'Default', 'rgb(21, 32, 43)': 'Dim', 'rgb(0, 0, 0)': 'LightsOut', }[$body.style.backgroundColor] } /** * @param {HTMLElement} $tweet * @param {?{getText?: boolean}} options * @returns {import("./types").QuotedTweet} */ function getQuotedTweetDetails($tweet, options = {}) { let {getText = false} = options let $quotedByLink = /** @type {HTMLAnchorElement} */ ($tweet.querySelector('[data-testid="User-Name"] a')) let $quotedTweet = $tweet.querySelector('div[id^="id__"] > div[dir] > span').parentElement.nextElementSibling let $userName = $quotedTweet?.querySelector('[data-testid="User-Name"]') let quotedBy = $quotedByLink?.pathname?.substring(1) let user = $userName?.querySelector('[tabindex="-1"]')?.textContent let time = $userName?.querySelector('time')?.dateTime if (!getText) return {quotedBy, user, time} let $heading = $quotedTweet?.querySelector(':scope > div > div:first-child') let $qtText = $heading?.nextElementSibling?.querySelector('[lang]') let text = $qtText && Array.from($qtText.childNodes, node => { if (node.nodeType == 1) { if (node.nodeName == 'IMG') return /** @type {HTMLImageElement} */ (node).alt return node.textContent } return node.nodeValue }).join('') return {quotedBy, user, time, text} } /** * Attempts to determine the type of a timeline Tweet given the element with * data-testid="tweet" on it, falling back to TWEET if it doesn't appear to be * one of the particular types we care about. * @param {HTMLElement} $tweet * @param {?boolean} checkSocialContext * @returns {import("./types").TweetType} */ function getTweetType($tweet, checkSocialContext = false) { if ($tweet.closest(Selectors.PROMOTED_TWEET_CONTAINER)) { return 'PROMOTED_TWEET' } // Assume social context tweets are Retweets if ($tweet.querySelector('[data-testid="socialContext"]')) { if (checkSocialContext) { let svgPath = $tweet.querySelector('svg path')?.getAttribute('d') ?? '' if (svgPath.startsWith('M7 4.5C7 3.12 8.12 2 9.5 2h5C1')) return 'PINNED_TWEET' } // Quoted tweets from accounts you blocked or muted are displayed as an // <article> with "This Tweet is unavailable." if ($tweet.querySelector('article')) { return 'UNAVAILABLE_RETWEET' } // Quoted tweets are preceded by visually-hidden "Quote" text if ($tweet.querySelector('div[id^="id__"] > div[dir] > span')?.textContent.includes(getString('QUOTE'))) { return 'RETWEETED_QUOTE_TWEET' } return 'RETWEET' } // Quoted tweets are preceded by visually-hidden "Quote" text if ($tweet.querySelector('div[id^="id__"] > div[dir] > span')?.textContent.includes(getString('QUOTE'))) { return 'QUOTE_TWEET' } // Quoted tweets from accounts you blocked or muted are displayed as an // <article> with "This Tweet is unavailable." if ($tweet.querySelector('article')) { return 'UNAVAILABLE_QUOTE_TWEET' } return 'TWEET' } // Add 1 every time this gets broken: 6 function getVerifiedProps($svg) { let propsGetter = (props) => props?.children?.props?.children?.[0]?.[0]?.props let $parent = $svg.parentElement.parentElement // Verified badge button on the profile screen if (isOnProfilePage() && $svg.parentElement.getAttribute('role') == 'button') { $parent = $svg.closest('span').parentElement } // Link variant in "user followed/liked/retweeted" notifications else if (isOnNotificationsPage() && $parent.getAttribute('role') == 'link') { propsGetter = (props) => { let linkChildren = props?.children?.props?.children?.[0] return linkChildren?.[linkChildren.length - 1]?.props } } if ($parent.wrappedJSObject) { $parent = $parent.wrappedJSObject } let reactPropsKey = Object.keys($parent).find(key => key.startsWith('__reactProps$')) let props = propsGetter($parent[reactPropsKey]) if (!props) { warn('React props not found for', $svg) } else if (!('isBlueVerified' in props)) { warn('isBlueVerified not in React props for', $svg, {props}) } return props } /** * @param {HTMLElement} $popup * @returns {{tookAction: boolean, onPopupClosed?: () => void}} */ function handlePopup($popup) { let r###lt = {tookAction: false, onPopupClosed: null} // Automatically close any sheet dialog which contains a Premium link if (desktop && config.hideTwitterBlueUpsells && $popup.querySelector('[data-testid="mask"]') && $popup.querySelector('[data-testid="sheetDialog"]') && $popup.querySelector('a[href^="/i/premium"]')) { log('hidePremiumUpsells: automatically closing Premium upsell dialog') let mask = /** @type {HTMLElement} */ ($popup.querySelector('[data-testid="mask"]')) mask.click() r###lt.tookAction = true return r###lt } // The Sort replies by menu is hydrated asynchronously if (isOnIndividualTweetPage() && config.sortReplies != 'relevant' && !userSortedReplies && $popup.innerHTML.includes(`>${getString('SORT_REPLIES_BY')}<`)) { log('sortReplies: Sort replies by menu opened') void (async () => { let $dropdown = await getElement('[role="menu"] [data-testid="Dropdown"]', { name: 'Rendered Sort replies by dropdown' }) let $menuItems = /** @type {NodeListOf<HTMLElement>} */ ($dropdown.querySelectorAll('div[role="menuitem"]')) let $selectedSvg = $popup.querySelector('div[role="menuitem"] svg') for (let [index, $menuItem] of $menuItems.entries()) { let shouldBeSelected = index == {recent: 1, liked: 2}[config.sortReplies] log({index, $menuItem, shouldBeSelected}) if (shouldBeSelected) { $menuItem.lastElementChild.append($selectedSvg) } $menuItem.addEventListener('click', () => { userSortedReplies = true }) } })() r###lt.tookAction = true return r###lt } if (desktop && !isDesktopComposeTweetModalOpen && location.pathname.startsWith(ModalPaths.COMPOSE_TWEET)) { log('Compose Tweet modal opened') isDesktopComposeTweetModalOpen = true $desktopComposeTweetModalPopup = $popup observeDesktopComposeTweetModal($popup) return { tookAction: true, onPopupClosed() { log('Compose Tweet modal closed') isDesktopComposeTweetModalOpen = false $desktopComposeTweetModalPopup = null disconnectAllModalObservers() // The Tweet button will re-render if the modal was opened to edit // multiple Tweets on the Home timeline. if (config.replaceLogo && isOnHomeTimelinePage()) { tweakTweetButton() } } } } if (desktop && !isDesktopMediaModalOpen && URL_MEDIA_RE.test(location.pathname) && currentPath != location.pathname) { log('media modal opened') isDesktopMediaModalOpen = true observeDesktopModalTimeline($popup) return { tookAction: true, onPopupClosed() { log('media modal closed') isDesktopMediaModalOpen = false disconnectAllModalObservers() } } } if (config.replaceLogo) { let $retweetDropdownItem = $popup.querySelector('div:is([data-testid="retweetConfirm"], [data-testid="repostConfirm"])') if ($retweetDropdownItem) { tweakRetweetDropdown($retweetDropdownItem, 'div:is([data-testid="retweetConfirm"], [data-testid="repostConfirm"])', 'RETWEET') return {tookAction: true} } let $unretweetDropdownItem = $popup.querySelector('div:is([data-testid="unretweetConfirm"], [data-testid="unrepostConfirm"])') if ($unretweetDropdownItem) { tweakRetweetDropdown($unretweetDropdownItem, 'div:is([data-testid="unretweetConfirm"], [data-testid="unrepostConfirm"])', 'UNDO_RETWEET') return {tookAction: true} } let $hoverLabel = $popup.querySelector('span[data-testid="HoverLabel"] > span') if ($hoverLabel?.textContent == getString('REPOST')) { $hoverLabel.textContent = getString('RETWEET') } } if (isOnListPage()) { let $switchSvg = $popup.querySelector(`svg path[d="M3 2h18.61l-3.5 7 3.5 7H5v6H3V2zm2 12h13.38l-2.5-5 2.5-5H5v10z"]`) if ($switchSvg) { addToggleListRetweetsMenuItem($popup.querySelector(`[role="menuitem"]`)) return {tookAction: true} } } if (config.mutableQuoteTweets) { if (quotedTweet) { let $blockMenuItem = /** @type {HTMLElement} */ ($popup.querySelector(Selectors.BLOCK_MENU_ITEM)) if ($blockMenuItem) { addMuteQuotesMenuItems($blockMenuItem) r###lt.tookAction = true // Clear the quoted tweet when the popup closes r###lt.onPopupClosed = () => { quotedTweet = null } } else { quotedTweet = null } } } if (config.fastBlock) { if (blockMenuItemSeen && $popup.querySelector('[data-testid="confirmationSheetConfirm"]')) { log('fast blocking') ;/** @type {HTMLElement} */ ($popup.querySelector('[data-testid="confirmationSheetConfirm"]')).click() r###lt.tookAction = true } else if ($popup.querySelector(Selectors.BLOCK_MENU_ITEM)) { log('preparing for fast blocking') blockMenuItemSeen = true // Create a nested observer for mobile, as it reuses the popup element r###lt.tookAction = !mobile } else { blockMenuItemSeen = false } } if (config.addAddMutedWordMenuItem) { let linkSelector = 'a[href$="/settings"]' let $link = /** @type {HTMLElement} */ ($popup.querySelector(linkSelector)) if ($link) { addAddMutedWordMenuItem($link, linkSelector) r###lt.tookAction = true } } if (config.twitterBlueChecks != 'ignore') { // User typeahead dropdown let $typeaheadDropdown = /** @type {HTMLElement} */ ($popup.querySelector('div[id^="typeaheadDropdown"]')) if ($typeaheadDropdown) { log('typeahead dropdown appeared') let observer = observeElement($typeaheadDropdown, () => { processBlueChecks($typeaheadDropdown) }, 'popup typeahead dropdown') return { tookAction: true, onPopupClosed() { log('typeahead dropdown closed') observer.disconnect() } } } } if (config.hideGrokNav || config.twitterBlueChecks != 'ignore') { // User hovercard popup let $hoverCard = /** @type {HTMLElement} */ ($popup.querySelector('[data-testid="HoverCard"]')) if ($hoverCard) { r###lt.tookAction = true getElement('div[data-testid^="UserAvatar-Container"]', { context: $hoverCard, name: 'user hovercard contents', timeout: 500, }).then(($contents) => { if (!$contents) return if (config.hideGrokNav) { // Tag Grok "Profile Summary" button let $grokButton = $popup.querySelector('[data-testid="HoverCard"] > div > div > div:last-child:has(> button)') if ($grokButton) { $grokButton.classList.add('GrokButton') } } if (config.twitterBlueChecks != 'ignore') { processBlueChecks($popup) } }) } } // Verified account popup when you press the check button on a profile page if (config.twitterBlueChecks == 'replace' && isOnProfilePage()) { if (mobile) { let $verificationBadge = /** @type {HTMLElement} */ ($popup.querySelector('[data-testid="sheetDialog"] [data-testid="verificationBadge"]')) if ($verificationBadge) { r###lt.tookAction = true let $headerBlueCheck = document.querySelector(`body.Profile ${Selectors.MOBILE_TIMELINE_HEADER} .tnt_blue_check`) if ($headerBlueCheck) { blueCheck($verificationBadge) } } } else { let $hoverCard = /** @type {HTMLElement} */ ($popup.querySelector('[data-testid="HoverCard"]')) if ($hoverCard) { r###lt.tookAction = true getElement(':scope > div > div > div > svg[data-testid="verificationBadge"]', { context: $hoverCard, name: 'verified account hovercard verification badge', timeout: 500, }).then(($verificationBadge) => { if (!$verificationBadge) return let $headerBlueCheck = document.querySelector(`body.Profile ${Selectors.PRIMARY_COLUMN} > div > div:first-of-type h2 .tnt_blue_check`) if (!$headerBlueCheck) return // Wait for the hovercard to render its contents let popupRenderObserver = observeElement($popup, (mutations) => { if (!mutations.length) return blueCheck($popup.querySelector('svg[data-testid="verificationBadge"]')) popupRenderObserver.disconnect() }, 'verified popup render', {childList: true, subtree: true}) }) } } } return r###lt } function isBlueVerified($svg) { let props = getVerifiedProps($svg) return Boolean(props && props.isBlueVerified && !( props.verifiedType || ( props.affiliateBadgeInfo?.userLabelType == 'BusinessLabel' && props.affiliateBadgeInfo?.description == 'X' ) )) } /** * @returns {import("./types").VerifiedType} */ function getVerifiedType($svg) { let props = getVerifiedProps($svg) if (props) { if (props.affiliateBadgeInfo?.userLabelType == 'BusinessLabel' && props.affiliateBadgeInfo?.description == 'X') // Ignore Twitter associated checks return null if (props.verifiedType == 'Business') return 'VERIFIED_ORG' if (props.isBlueVerified) return 'BLUE' } return null } /** * Checks if a tweet is preceded by an element creating a vertical reply line. * @param {HTMLElement} $tweet * @returns {boolean} */ function isReplyToPreviousTweet($tweet) { let $replyLine = $tweet.firstElementChild?.firstElementChild?.firstElementChild?.firstElementChild?.firstElementChild?.firstElementChild if ($replyLine) { return getComputedStyle($replyLine).width == '2px' } } /** * @returns {{disconnect()}} */ function onPopup($popup) { log('popup appeared', $popup, location.pathname) // If handlePopup did something, we don't need to observe nested popups let {tookAction, onPopupClosed} = handlePopup($popup) if (tookAction) { return onPopupClosed ? {disconnect: onPopupClosed} : null } /** @type {HTMLElement} */ let $nestedPopup let nestedObserver = observeElement($popup, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {HTMLElement} */ $el) => { log('nested popup appeared', $el) $nestedPopup = $el ;({onPopupClosed} = handlePopup($el)) }) mutation.removedNodes.forEach((/** @type {HTMLElement} */ $el) => { if ($el !== $nestedPopup) return if (onPopupClosed) { log('cleaning up after nested popup removed') onPopupClosed() } }) }) }, 'nested popup observer') let disconnect = nestedObserver.disconnect.bind(nestedObserver) nestedObserver.disconnect = () => { if (onPopupClosed) { log('cleaning up after nested popup observer disconnected') onPopupClosed() } disconnect() } return nestedObserver } /** * @param {HTMLElement} $timeline * @param {string} page * @param {import("./types").TimelineOptions?} options */ function onTimelineChange($timeline, page, options = {}) { let startTime = Date.now() let {classifyTweets = true, hideHeadings = true, isUserTimeline = false} = options let isOnHomeTimeline = isOnHomeTimelinePage() let isOnListTimeline = isOnListPage() let isOnProfileTimeline = isOnProfilePage() let timelineHasSpecificHandling = isOnHomeTimeline || isOnListTimeline || isOnProfileTimeline if (config.twitterBlueChecks != 'ignore' && (isUserTimeline || !timelineHasSpecificHandling)) { processBlueChecks($timeline) } if (isSafari && config.replaceLogo && isOnNotificationsPage()) { processTwitterLogos($timeline) } if (isUserTimeline || !classifyTweets) return let itemTypes = {} let hiddenItemCount = 0 let hiddenItemTypes = {} /** @type {?boolean} */ let hidPreviousItem = null /** @type {{$item: Element, hideItem?: boolean}[]} */ let changes = [] for (let $item of $timeline.children) { /** @type {?import("./types").TimelineItemType} */ let itemType = null /** @type {?boolean} */ let hideItem = null /** @type {?HTMLElement} */ let $tweet = $item.querySelector(Selectors.TWEET) /** @type {boolean} */ let isReply = false /** @type {boolean} */ let isBlueTweet = false if ($tweet != null) { itemType = getTweetType($tweet, isOnProfileTimeline) if (timelineHasSpecificHandling) { isReply = isReplyToPreviousTweet($tweet) if (isReply && hidPreviousItem != null) { hideItem = hidPreviousItem } else { if (isOnHomeTimeline) { hideItem = shouldHideHomeTimelineItem(itemType, page) if (config.mutableQuoteTweets && !hideItem && itemType == 'QUOTE_TWEET' && config.hideQuotesFrom.length > 0) { let $quotedByLink = /** @type {HTMLAnchorElement} */ ($tweet.querySelector('[data-testid="User-Name"] a')) let quotedBy = $quotedByLink?.pathname.substring(1) if (quotedBy) { hideItem = config.hideQuotesFrom.includes(quotedBy) } else { warn('hideQuotesFrom: unable to get quote tweet user') } } } else if (isOnListTimeline) { hideItem = shouldHideListTimelineItem(itemType) } else if (isOnProfileTimeline) { hideItem = shouldHideProfileTimelineItem(itemType) } } if (!hideItem && config.hideGrokTweets && $tweet.querySelector('a[href^="/i/grok/share/"]')) { hideItem = true } if (!hideItem && config.mutableQuoteTweets && (itemType == 'QUOTE_TWEET' || itemType == 'RETWEETED_QUOTE_TWEET')) { if (config.mutedQuotes.length > 0) { let quotedTweet = getQuotedTweetDetails($tweet) hideItem = config.mutedQuotes.some(muted => muted.user == quotedTweet.user && muted.time == quotedTweet.time) } if (!hideItem) { addCaretMenuListenerForQuoteTweet($tweet) } } if (config.twitterBlueChecks != 'ignore') { for (let $svg of $tweet.querySelectorAll(Selectors.VERIFIED_TICK)) { let isBlueCheck = isBlueVerified($svg) if (!isBlueCheck) continue blueCheck($svg) // Don't count a tweet as blue if the check is in a quoted tweet let userProfileLink = $svg.closest('a[role="link"]:not([href^="/i/status"])') if (!userProfileLink) continue isBlueTweet = true } } } if (!hideItem && config.restoreLinkHeadlines) { restoreLinkHeadline($tweet) } } else if (!timelineHasSpecificHandling) { if ($item.querySelector(':scope > div > div > div > article')) { itemType = 'UNAVAILABLE' } } if (!timelineHasSpecificHandling) { if (itemType != null) { hideItem = shouldHideOtherTimelineItem(itemType) } } // Special handling for non-Tweet timeline items if (itemType == null) { if ($item.querySelector('[data-testid="inlinePrompt"]')) { itemType = 'INLINE_PROMPT' hideItem = config.hideInlinePrompts || ( config.hideTwitterBlueUpsells && Boolean($item.querySelector('a[href^="/i/premium"]')) || config.hideMonetizationNav && Boolean($item.querySelector('a[href="/settings/monetization"]')) ) } else if ($item.querySelector(Selectors.TIMELINE_HEADING)) { itemType = 'HEADING' hideItem = hideHeadings && config.hideWhoToFollowEtc } } if (debug && itemType != null) { $item.firstElementChild.setAttribute('data-item-type', `${itemType}${isReply ? ' / REPLY' : ''}${isBlueTweet ? ' / BLUE' : ''}`) } // Assume a non-identified item following an identified item is related if (itemType == null && hidPreviousItem != null) { hideItem = hidPreviousItem itemType = 'SUBSEQUENT_ITEM' } if (itemType != null) { itemTypes[itemType] ||= 0 itemTypes[itemType]++ } if (hideItem) { hiddenItemCount++ hiddenItemTypes[itemType] ||= 0 hiddenItemTypes[itemType]++ } if (hideItem != null && $item.firstElementChild) { let hidden = $item.firstElementChild.classList.contains('HiddenTweet') if (hidden != hideItem) { changes.push({$item, hideItem}) } } hidPreviousItem = hideItem } for (let change of changes) { change.$item.firstElementChild.classList.toggle('HiddenTweet', change.hideItem) } if (debug && config.debugLogTimelineStats) { log( `processed ${$timeline.children.length} timeline item${s($timeline.children.length)} in ${Date.now() - startTime}ms`, itemTypes, `hid ${hiddenItemCount}`, hiddenItemTypes ) } } /** * @param {HTMLElement} $timeline * @param {import("./types").IndividualTweetTimelineOptions} options */ function onIndividualTweetTimelineChange($timeline, options) { let startTime = Date.now() let itemTypes = {} let hiddenItemCount = 0 let hiddenItemTypes = {} /** @type {?boolean} */ let hidPreviousItem = null /** @type {boolean} */ let hideAllSubsequentItems = false /** @type {string} */ let opScreenName = /^\/([a-zA-Z\d_]{1,20})\//.exec(location.pathname)[1].toLowerCase() /** @type {{$item: Element, hideItem?: boolean}[]} */ let changes = [] /** @type {import("./types").UserInfoObject} */ let userInfo = getUserInfo() /** @type {?HTMLElement} */ let $focusedTweet for (let $item of $timeline.children) { /** @type {?import("./types").TimelineItemType} */ let itemType = null /** @type {?boolean} */ let hideItem = null /** @type {?HTMLElement} */ let $tweet = $item.querySelector(Selectors.TWEET) /** @type {boolean} */ let isFocusedTweet = false /** @type {boolean} */ let isReply = false /** @type {import("./types").VerifiedType} */ let tweetVerifiedType = null /** @type {?string} */ let screenName = null if (hideAllSubsequentItems) { hideItem = true itemType = 'DISCOVER_MORE_TWEET' } else if ($tweet != null) { isFocusedTweet = $tweet.tabIndex == -1 isReply = isReplyToPreviousTweet($tweet) if (isFocusedTweet) { itemType = 'FOCUSED_TWEET' hideItem = false $focusedTweet = $tweet } else { itemType = getTweetType($tweet) if (isReply && hidPreviousItem != null) { hideItem = hidPreviousItem } else { hideItem = shouldHideIndividualTweetTimelineItem(itemType) } } if (!hideItem && config.hideGrokTweets && $tweet.querySelector('a[href^="/i/grok/share/"]')) { hideItem = true } if (!hideItem && (config.twitterBlueChecks != 'ignore' || config.hideTwitterBlueReplies)) { for (let $svg of $tweet.querySelectorAll(Selectors.VERIFIED_TICK)) { let verifiedType = getVerifiedType($svg) if (!verifiedType) continue if (config.twitterBlueChecks != 'ignore' && verifiedType == 'BLUE') { blueCheck($svg) } // Don't count a tweet as verified if the check is in a quoted tweet let $userProfileLink = /** @type {HTMLAnchorElement} */ ($svg.closest('a[role="link"]:not([href^="/i/status"])')) if (!$userProfileLink) continue tweetVerifiedType = verifiedType screenName = $userProfileLink.href.split('/').pop() } // Replies to the focused tweet don't have the reply indicator if (tweetVerifiedType && !isFocusedTweet && !isReply && screenName.toLowerCase() != opScreenName) { itemType = `${tweetVerifiedType}_REPLY` if (!hideItem) { let user = userInfo[screenName] let shouldHideBasedOnVerifiedType = config.hideTwitterBlueReplies && ( tweetVerifiedType == 'BLUE' || tweetVerifiedType == 'VERIFIED_ORG' && !config.showBlueReplyVerifiedAccounts ) hideItem = shouldHideBasedOnVerifiedType && (user == null || !( user.following && !config.hideBlueReplyFollowing || user.followedBy && !config.hideBlueReplyFollowedBy || config.showBlueReplyFollowersCount && user.followersCount >= Number(config.showBlueReplyFollowersCountAmount) )) } } } if (!hideItem && config.restoreLinkHeadlines) { restoreLinkHeadline($tweet) } } else { let $article = $item.querySelector('article') if ($article) { // Deleted or private, unless… itemType = 'UNAVAILABLE' let $button = $article.querySelector('[role="button"]') if ($button) { if ($button.textContent == getString('SHOW')) { itemType = 'SHOW_MORE' } else if ($button.textContent == getString('VIEW')) { // "This Tweet is from an account you (blocked|muted)." with a View button hideItem = config.hideUnavailableQuoteTweets } } else if ($article.textContent == getString('POST_UNAVAILABLE')) { // Likely blocked or muted hideItem = config.hideUnavailableQuoteTweets } } else { // We need to identify "Show more replies" so it doesn't get hidden if the // item immediately before it was hidden. let $button = $item.querySelector('button[role="button"]') if ($button) { if ($button?.textContent == getString('SHOW_MORE_REPLIES')) { itemType = 'SHOW_MORE' } } else { let $heading = $item.querySelector(Selectors.TIMELINE_HEADING) if ($heading) { // Discover More headings have a description next to them if ($heading.nextElementSibling && $heading.nextElementSibling.tagName == 'DIV' && $heading.nextElementSibling.getAttribute('dir') != null) { itemType = 'DISCOVER_MORE_HEADING' hideItem = config.hideMoreTweets hideAllSubsequentItems = config.hideMoreTweets } else { itemType = 'HEADING' } } } } } if (debug && itemType != null) { $item.firstElementChild.setAttribute('data-item-type', `${itemType}${isReply ? ' / REPLY' : ''}`) } // Assume a non-identified item following an identified item is related if (itemType == null && hidPreviousItem != null) { hideItem = hidPreviousItem itemType = 'SUBSEQUENT_ITEM' } if (itemType != null) { itemTypes[itemType] ||= 0 itemTypes[itemType]++ } if (hideItem) { hiddenItemCount++ hiddenItemTypes[itemType] ||= 0 hiddenItemTypes[itemType]++ } if (isFocusedTweet) { // Tweets prior to the focused tweet should never be hidden changes = [] hiddenItemCount = 0 hiddenItemTypes = {} } else if (hideItem != null && $item.firstElementChild) { let hidden = $item.firstElementChild.classList.contains('HiddenTweet') if (hidden != hideItem) { changes.push({$item, hideItem}) } } hidPreviousItem = hideItem } for (let change of changes) { change.$item.firstElementChild.classList.toggle('HiddenTweet', change.hideItem) } tweakFocusedTweet($focusedTweet, options) if (debug && config.debugLogTimelineStats) { log( `processed ${$timeline.children.length} thread item${s($timeline.children.length)} in ${Date.now() - startTime}ms`, itemTypes, `hid ${hiddenItemCount}`, hiddenItemTypes ) } } /** * Title format (including notification count): * - LTR: (3) ${title} / X * - RTL: (3) X \ ${title} * @param {string} title */ function onTitleChange(title) { log('title changed', {title, path: location.pathname}) if (checkforDisabledHomeTimeline()) return // Ignore leading notification counts in titles let notificationCount = '' if (TITLE_NOTIFICATION_RE.test(title)) { notificationCount = TITLE_NOTIFICATION_RE.exec(title)[0] title = title.replace(TITLE_NOTIFICATION_RE, '') } // After we replace the shortcut icon, Twitter stops updating it to add/remove // the notifications pip, so we need to manage the pip ourselves. if (config.replaceLogo && Boolean(notificationCount) != Boolean(currentNotificationCount)) { observeFavicon.forceUpdate(Boolean(notificationCount)) } let homeNavigationWasUsed = homeNavigationIsBeingUsed homeNavigationIsBeingUsed = false // Ignore Flash of Uninitialised Title when navigating to a page for the first // time, except in scenarios where we know an empty title is being set. if (title == 'X' || title == getString('TWITTER')) { // On mobile, the media viewer sets an empty title if (mobile && (URL_MEDIA_RE.test(location.pathname) || URL_MEDIAVIEWER_RE.test(location.pathname))) { log('viewing media on mobile') } // On desktop, the root Settings page sets an empty title when the sidebar // is hidden. else if (desktop && location.pathname == '/settings' && currentPath != '/settings') { log('viewing root Settings page') } // On desktop, the root Messages page sometimes sets an empty title else if (desktop && location.pathname == '/messages' && currentPath != '/messages') { log('viewing root Messages page') } // The Bookmarks page sets an empty title else if (location.pathname.startsWith(PagePaths.BOOKMARKS) && !currentPath.startsWith(PagePaths.BOOKMARKS)) { log('viewing Bookmarks page') } else { log('ignoring Flash of Uninitialised Title') return } } // Remove " / Twitter" or "Twitter \ " from the title let newPage = title if (newPage != 'X' && newPage != getString('TWITTER')) { newPage = title.slice(...ltr ? [0, title.lastIndexOf('/') - 1] : [title.indexOf('\\') + 2]) } let hasDesktopModalBeenOpenedOrClosed = desktop && ( // Timeline settings dialog opened location.pathname == PagePaths.TIMELINE_SETTINGS || // Timeline settings dialog closed currentPath == PagePaths.TIMELINE_SETTINGS || // Media modal opened URL_MEDIA_RE.test(location.pathname) || // Media modal closed URL_MEDIA_RE.test(currentPath) || // "Send via Direct Message" dialog opened location.pathname == ModalPaths.COMPOSE_MESSAGE || // "Send via Direct Message" dialog closed currentPath == ModalPaths.COMPOSE_MESSAGE || // Compose Tweet dialog opened location.pathname == ModalPaths.COMPOSE_TWEET || // Compose Tweet dialog closed currentPath == ModalPaths.COMPOSE_TWEET ) if (newPage == currentPage) { log(`ignoring duplicate title change`) // Navigation within the Compose Tweet modal triggers duplcate title changes if (isDesktopComposeTweetModalOpen) { if (currentPath == ModalPaths.COMPOSE_TWEET && COMPOSE_TWEET_MODAL_PAGES.has(location.pathname)) { log('navigated away from Compose Tweet editor') disconnectAllModalObservers() } else if (COMPOSE_TWEET_MODAL_PAGES.has(currentPath) && location.pathname == ModalPaths.COMPOSE_TWEET) { log('navigated back to Compose Tweet editor') observeDesktopComposeTweetModal($desktopComposeTweetModalPopup) } } currentNotificationCount = notificationCount currentPath = location.pathname return } // Search terms are shown in the title if (currentPath == PagePaths.SEARCH && location.pathname == PagePaths.SEARCH) { log('ignoring title change on Search page') currentNotificationCount = notificationCount return } // On desktop, stay on the separated tweets timeline when… if (desktop && currentPage == separatedTweetsTimelineTitle && // …the title has changed back to the Home timeline… (newPage == getString('HOME')) && // …the Home nav link or Following / Home header _wasn't_ clicked and… !homeNavigationWasUsed && ( // …a modal which changes the pathname has been opened or closed. hasDesktopModalBeenOpenedOrClosed || // …the notification count in the title changed. notificationCount != currentNotificationCount )) { log('ignoring title change on separated tweets timeline') currentNotificationCount = notificationCount currentPath = location.pathname setTitle(separatedTweetsTimelineTitle) return } // Restore display of the separated tweets timelne if it's the last one we // saw, and the user navigated back home without using the Home navigation // item. if (location.pathname == PagePaths.HOME && currentPath != PagePaths.HOME && !homeNavigationWasUsed && lastHomeTimelineTitle != null && separatedTweetsTimelineTitle != null && lastHomeTimelineTitle == separatedTweetsTimelineTitle) { log('restoring display of the separated tweets timeline') currentNotificationCount = notificationCount currentPath = location.pathname setTitle(separatedTweetsTimelineTitle) return } // Assumption: all non-FOUT, non-duplicate title changes are navigation, which // need the page to be re-processed. currentPage = newPage currentNotificationCount = notificationCount currentPath = location.pathname if (isOnHomeTimelinePage()) { lastHomeTimelineTitle = currentPage } log('processing new page') processCurrentPage() } /** * Processes all Twitter Blue checks inside an element. * @param {HTMLElement} $el */ function processBlueChecks($el) { for (let $svg of $el.querySelectorAll(`${Selectors.VERIFIED_TICK}:not(.tnt_blue_check)`)) { if (isBlueVerified($svg)) { blueCheck($svg) } } } /** * Processes all Twitter logos inside an element. */ function processTwitterLogos($el) { for (let $svgPath of $el.querySelectorAll(Selectors.X_LOGO_PATH)) { twitterLogo($svgPath) } } function processCurrentPage() { if (pageObservers.length > 0) { log( `disconnecting ${pageObservers.length} page observer${s(pageObservers.length)}`, pageObservers.map(observer => observer['name']) ) pageObservers.forEach(observer => observer.disconnect()) pageObservers = [] } // Hooks for styling pages $body.classList.toggle('Bookmarks', isOnBookmarksPage()) $body.classList.toggle('Community', isOnCommunityPage()) $body.classList.toggle('Communities', isOnCommunitiesPage()) $body.classList.toggle('Explore', isOnExplorePage()) $body.classList.toggle('HideSidebar', shouldHideSidebar()) $body.classList.toggle('List', isOnListPage()) $body.classList.toggle('HomeTimeline', isOnHomeTimelinePage()) $body.classList.toggle('Notifications', isOnNotificationsPage()) $body.classList.toggle('Profile', isOnProfilePage()) if (!isOnProfilePage()) { $body.classList.remove('OwnProfile', 'PremiumProfile') } $body.classList.toggle('ProfileFollows', isOnFollowListPage()) if (!isOnFollowListPage()) { $body.classList.remove('Subscriptions') } $body.classList.toggle('QuoteTweets', isOnQuoteTweetsPage()) $body.classList.toggle('Tweet', isOnIndividualTweetPage()) $body.classList.toggle('Search', isOnSearchPage()) $body.classList.toggle('Settings', isOnSettingsPage()) $body.classList.toggle('MobileMedia', mobile && URL_MEDIA_RE.test(location.pathname)) $body.classList.toggle('MediaViewer', mobile && URL_MEDIAVIEWER_RE.test(location.pathname)) $body.classList.remove('SeparatedTweets') if (desktop) { let shouldObserveSidebarForConfig = ( config.twitterBlueChecks != 'ignore' || config.fullWidthContent || config.hideExploreNav && config.hideExploreNavWithSidebar ) if (shouldObserveSidebarForConfig && !isOnMessagesPage() && !isOnSettingsPage()) { observeSidebar() } else { $body.classList.remove('Sidebar') } if (isSafari && config.replaceLogo) { tweakDesktopLogo() } } if (isSafari && config.replaceLogo) { tweakHomeIcon() } if (config.twitterBlueChecks != 'ignore' && (isOnSearchPage() || isOnExplorePage())) { observeSearchForm() } if (isOnHomeTimelinePage()) { tweakHomeTimelinePage() } else { removeMobileTimelineHeaderElements() } if (isOnProfilePage()) { tweakProfilePage() } else if (isOnFollowListPage()) { tweakFollowListPage() } else if (isOnIndividualTweetPage()) { tweakIndividualTweetPage() } else if (isOnNotificationsPage()) { tweakNotificationsPage() } else if (isOnSearchPage()) { tweakSearchPage() } else if (URL_TWEET_ENGAGEMENT_RE.test(currentPath)) { tweakTweetEngagementPage() } else if (isOnListPage()) { tweakListPage() } else if (isOnListsPage()) { tweakListsPage() } else if (isOnExplorePage()) { tweakExplorePage() } else if (isOnBookmarksPage()) { tweakBookmarksPage() } else if (isOnCommunitiesPage()) { tweakCommunitiesPage() } else if (isOnCommunityPage()) { tweakCommunityPage() } else if (isOnCommunityMembersPage()) { tweakCommunityMembersPage() } else if (isOnDisplaySettingsPage() || isOnAccessibilitySettingsPage()) { tweakDisplaySettingsPage() } // On mobile, these are pages instead of modals if (mobile) { if (currentPath == PagePaths.COMPOSE_TWEET) { tweakMobileComposeTweetPage() } else if (URL_MEDIAVIEWER_RE.test(currentPath)) { tweakMobileMediaViewerPage() } } } /** * @returns {boolean} `true` if this call replaces the current location */ function redirectToTwitter() { if (config.redirectToTwitter && location.hostname.endsWith('x.com') && // Don't redirect the path used by the OldTweetDeck extension location.pathname != '/i/tweetdeck') { // If we got a logout redirect from twitter.com, redirect back to the login page let pathname = location.search.includes('logout=') ? '/i/flow/login' : location.pathname || '/home' let redirectUrl = `https://twitter.com${pathname}?mx=1` log('redirectToTwitter: redirecting from', location.href, 'to', redirectUrl) location.replace(redirectUrl) return true } return false } /** * The mobile version of Twitter reuses heading elements between screens, so we * always remove any elements which could be there from the previous page and * re-add them later when needed. */ function removeMobileTimelineHeaderElements() { if (mobile) { document.querySelector('#tnt_separated_tweets_tab')?.remove() } } /** * @param {HTMLElement} $tweet */ function restoreLinkHeadline($tweet) { let $link = /** @type {HTMLElement} */ ($tweet.querySelector('div[data-testid="card.layoutLarge.media"] > a[rel][aria-label]')) if ($link && !$link.dataset.headlineRestored) { let [site, ...rest] = $link.getAttribute('aria-label').split(' ') let headline = rest.join(' ') $link.lastElementChild?.classList.add('tnt_overlay_headline') $link.insertAdjacentHTML('beforeend', `<div class="tnt_link_headline ${fontFamilyRule?.selectorText?.replace('.', '') || 'tnt_font_family'}" style="border-top: 1px solid var(--border-color); padding: 14px;"> <div style="color: var(--color); margin-bottom: 2px;">${site}</div> <div style="color: var(--color-emphasis)">${headline}</div> </div>`) $link.dataset.headlineRestored = 'true' } } /** * @param {HTMLElement} $focusedTweet */ function restoreTweetInteractionsLinks($focusedTweet) { if (!config.restoreQuoteTweetsLink && !config.restoreOtherInteractionLinks) return let [tweetLink, tweetId] = location.pathname.match(/^\/[a-zA-Z\d_]{1,20}\/status\/(\d+)/) ?? [] let tweetInfo = getTweetInfo(tweetId) log('focused tweet', {tweetLink, tweetId, tweetInfo}) if (!tweetInfo) return let isOwnTweet = Boolean($focusedTweet.querySelector('a[data-testid="analyticsButton"]')) let shouldDisplayLinks = ( (config.restoreQuoteTweetsLink && tweetInfo.quote_count > 0) || (config.restoreOtherInteractionLinks && (tweetInfo.retweet_count > 0 || isOwnTweet && tweetInfo.favorite_count > 0)) ) let $existingLinks = $focusedTweet.querySelector('#tntInteractionLinks') if (!shouldDisplayLinks || $existingLinks) { if (!shouldDisplayLinks) $existingLinks?.remove() return } let $group = $focusedTweet.querySelector('[role="group"][id^="id__"]') if (!$group) return warn('focused tweet action bar not found') $group.parentElement.insertAdjacentHTML('beforebegin', ` <div id="tntInteractionLinks"> <div class="${fontFamilyRule?.selectorText?.replace('.', '') || 'tnt_font_family'}" style="padding: 16px 4px; border-top: 1px solid var(--border-color); display: flex; gap: 20px;"> ${tweetInfo.quote_count > 0 ? `<a id="tntQuoteTweetsLink" class="quoteTweets" href="${tweetLink}/quotes" dir="auto" role="link"> <span id="tntQuoteTweetCount"> ${Intl.NumberFormat(lang, {notation: tweetInfo.quote_count < 10000 ? 'standard' : 'compact', compactDisplay: 'short'}).format(tweetInfo.quote_count)} </span> <span>${getString(tweetInfo.quote_count == 1 ? (config.replaceLogo ? 'QUOTE_TWEET' : 'QUOTE') : (config.replaceLogo ? 'QUOTE_TWEETS' : 'QUOTES'))}</span> </a>` : ''} ${tweetInfo.retweet_count > 0 ? `<a id="tntRetweetsLink" data-tab="2" href="${tweetLink}/retweets" dir="auto" role="link"> <span id="tntRetweetCount"> ${Intl.NumberFormat(lang, {notation: tweetInfo.retweet_count < 10000 ? 'standard' : 'compact', compactDisplay: 'short'}).format(tweetInfo.retweet_count)} </span> <span>${getString(config.replaceLogo ? 'RETWEETS' : 'REPOSTS')}</span> </a>` : ''} ${isOwnTweet && tweetInfo.favorite_count > 0 ? `<a id="tntLikesLink" data-tab="3" href="${tweetLink}/likes" dir="auto" role="link"> <span id="tntLikeCount"> ${Intl.NumberFormat(lang, {notation: tweetInfo.favorite_count < 10000 ? 'standard' : 'compact', compactDisplay: 'short'}).format(tweetInfo.favorite_count)} </span> <span>${getString('LIKES')}</span> </a>` : ''} </div> </div> `) let links = /** @type {NodeListOf<HTMLAnchorElement>} */ ($focusedTweet.querySelectorAll('#tntInteractionLinks a')) links.forEach(($link) => { $link.addEventListener('click', async (e) => { let $caret = /** @type {HTMLElement} */ ($focusedTweet.querySelector('[data-testid="caret"]')) if (!$caret) return warn('focused tweet menu caret not found') log('clicking "View post engagements" menu item') e.preventDefault() $caret.click() let $tweetEngagements = await getElement('#layers a[data-testid="tweetEngagements"]', { name: 'View post engagements menu item', stopIf: pageIsNot(currentPage), timeout: 500, }) if ($tweetEngagements) { tweetInteractionsTab = $link.dataset.tab || null $tweetEngagements.click() } else { warn('falling back to full page refresh') location.href = $link.href } }) }) } /** * Sets the page name in <title>, retaining any current notification count. * @param {string} page */ function setTitle(page) { let name = config.replaceLogo ? getString('TWITTER') : 'X' let notificationCount = config.hideNotifications != 'ignore' ? ( '' ) : ( hiddenNotificationCount || currentNotificationCount ) document.title = ltr ? ( `${notificationCount}${page} / ${name}` ) : ( `${notificationCount}${name} \\ ${page}` ) } /** * @param {import("./types").TimelineItemType} type * @returns {boolean} */ function shouldHideIndividualTweetTimelineItem(type) { switch (type) { case 'QUOTE_TWEET': case 'RETWEET': case 'RETWEETED_QUOTE_TWEET': case 'TWEET': return false case 'UNAVAILABLE_QUOTE_TWEET': case 'UNAVAILABLE_RETWEET': return config.hideUnavailableQuoteTweets default: return true } } /** * @param {import("./types").TimelineItemType} type * @returns {boolean} */ function shouldHideListTimelineItem(type) { switch (type) { case 'RETWEET': case 'RETWEETED_QUOTE_TWEET': return config.listRetweets == 'hide' case 'UNAVAILABLE_QUOTE_TWEET': return config.hideUnavailableQuoteTweets case 'UNAVAILABLE_RETWEET': return config.hideUnavailableQuoteTweets || config.listRetweets == 'hide' default: return false } } /** * @param {import("./types").TimelineItemType} type * @param {string} page * @returns {boolean} */ function shouldHideHomeTimelineItem(type, page) { switch (type) { case 'QUOTE_TWEET': return shouldHideSharedTweet(config.quoteTweets, page) case 'RETWEET': return selectedHomeTabIndex >= 2 ? config.listRetweets == 'hide' : shouldHideSharedTweet(config.retweets, page) case 'RETWEETED_QUOTE_TWEET': return selectedHomeTabIndex >= 2 ? ( config.listRetweets == 'hide' ) : ( shouldHideSharedTweet(config.retweets, page) || shouldHideSharedTweet(config.quoteTweets, page) ) case 'TWEET': return page == separatedTweetsTimelineTitle case 'UNAVAILABLE_QUOTE_TWEET': return config.hideUnavailableQuoteTweets || shouldHideSharedTweet(config.quoteTweets, page) case 'UNAVAILABLE_RETWEET': return config.hideUnavailableQuoteTweets || selectedHomeTabIndex >= 2 ? config.listRetweets == 'hide' : shouldHideSharedTweet(config.retweets, page) default: return true } } /** * @param {import("./types").TimelineItemType} type * @returns {boolean} */ function shouldHideProfileTimelineItem(type) { switch (type) { case 'PINNED_TWEET': case 'QUOTE_TWEET': case 'TWEET': return false case 'RETWEET': case 'RETWEETED_QUOTE_TWEET': return config.hideProfileRetweets case 'UNAVAILABLE_QUOTE_TWEET': return config.hideUnavailableQuoteTweets default: return true } } /** * @param {import("./types").TimelineItemType} type * @returns {boolean} */ function shouldHideOtherTimelineItem(type) { switch (type) { case 'QUOTE_TWEET': case 'RETWEET': case 'RETWEETED_QUOTE_TWEET': case 'TWEET': case 'UNAVAILABLE': case 'UNAVAILABLE_QUOTE_TWEET': case 'UNAVAILABLE_RETWEET': return false default: return true } } /** * @param {import("./types").SharedTweetsConfig} config * @param {string} page * @returns {boolean} */ function shouldHideSharedTweet(config, page) { switch (config) { case 'hide': return true case 'ignore': return page == separatedTweetsTimelineTitle case 'separate': return page != separatedTweetsTimelineTitle } } async function tweakBookmarksPage() { if (config.twitterBlueChecks != 'ignore' || config.restoreLinkHeadlines) { observeTimeline(currentPage) } } async function tweakExplorePage() { if (!config.hideExplorePageContents) return let $searchInput = await getElement('input[data-testid="SearchBox_Search_Input"]', { name: 'explore page search input', stopIf: () => !isOnExplorePage(), }) if (!$searchInput) return log('focusing search input') $searchInput.focus() if (mobile) { // The back button appears after the search input is focused on mobile. When // you tap it or otherwise navigate back, it's replaced with the slide-out // menu button and Explore page contents are shown - we want to skip that. let $backButton = await getElement('div[data-testid="app-bar-back"]', { name: 'back button', stopIf: () => !isOnExplorePage(), }) if (!$backButton) return pageObservers.push( observeElement($backButton.parentElement, (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {HTMLElement} */ $el) => { if ($el.querySelector('[data-testid="DashButton_ProfileIcon_Link"]')) { log('slide-out menu button appeared, going back to skip Explore page') history.go(-2) } }) }) }, 'back button parent') ) } } function tweakCommunitiesPage() { observeTimeline(currentPage) } function tweakCommunityPage() { if (config.twitterBlueChecks != 'ignore') { observeTimeline(currentPage, { classifyTweets: false, isTabbed: true, tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`, onTimelineAppeared() { // The About tab has static content at the top which can include a check if (/\/about\/?$/.test(location.pathname)) { processBlueChecks(document.querySelector(Selectors.PRIMARY_COLUMN)) } } }) } } function tweakCommunityMembersPage() { if (config.twitterBlueChecks != 'ignore') { observeTimeline(currentPage, { classifyTweets: false, isTabbed: true, timelineSelector: 'div[data-testid="primaryColumn"] > div > div:last-child', }) } } function tweakDisplaySettingsPage() { (async () => { let $colorRerenderBoundary = await getElement('#react-root > div > div') pageObservers.push( observeElement($colorRerenderBoundary, () => { let newThemeColor = getThemeColorFromState() if (newThemeColor == themeColor) return log('Color setting changed') themeColor = newThemeColor configureThemeCss() observePopups() observeSideNavTweetButton() }, 'Color change re-render boundary') ) })() if (desktop) { pageObservers.push( observeElement($html, () => { if (!$html.style.fontSize) return if ($html.style.fontSize != fontSize) { fontSize = $html.style.fontSize log(`<html> fontSize has changed to ${fontSize}`) configureDynamicCss() observePopups() observeSideNavTweetButton() } }, '<html> style attribute for font size changes', { attributes: true, attributeFilter: ['style'] }) ) } } const tweakFocusedTweet = (() => { let waitingForFocusedTweetEditor = false /** * @param {HTMLElement} $focusedTweet * @param {import("./types").IndividualTweetTimelineOptions} options */ return async function tweakFocusedTweet($focusedTweet, options) { let {observers} = options if (!$focusedTweet) { if (desktop) { waitingForFocusedTweetEditor = false disconnectObserver('tweet editor', observers) } return } tweakOwnFocusedTweet($focusedTweet) restoreTweetInteractionsLinks($focusedTweet) if (desktop && config.replaceLogo && !waitingForFocusedTweetEditor && !isObserving(observers, 'tweet editor')) { waitingForFocusedTweetEditor = true /** @type {HTMLElement} */ let $editorRoot try { $editorRoot = await getElement('.DraftEditor-root', { context: $focusedTweet.parentElement, name: 'tweet editor in focused tweet', timeout: 500, stopIf: () => !waitingForFocusedTweetEditor }) } finally { waitingForFocusedTweetEditor = false } if ($editorRoot) { observeDesktopTweetEditorPlaceholder($editorRoot, { name: 'tweet editor', placeholder: getString('TWEET_YOUR_REPLY'), observers, }) } } } })() async function tweakFollowListPage() { // These tabs are dynamic as "Followers you know" only appears when applicable let $tabs = await getElement(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`, { name: 'Following tabs', stopIf: pageIsNot(currentPage), }) if (!$tabs) return let $subscriptionsTabLink = $tabs.querySelector('div[role="tablist"] a[href$="/subscriptions"]') if ($subscriptionsTabLink) { $body.classList.add('Subscriptions') } if (config.hideVerifiedNotificationsTab) { let isVerifiedTabSelected = Boolean($tabs.querySelector('div[role="tablist"] > div:nth-child(1) > a[aria-selected="true"]')) if (isVerifiedTabSelected) { log('switching to Following tab') let $followingTab = /** @type {HTMLAnchorElement} */ ( $tabs.querySelector(`div[role="tablist"] > div:nth-last-child(${$subscriptionsTabLink ? 3 : 2}) > a`) ) $followingTab?.click() } } if (config.twitterBlueChecks != 'ignore') { observeTimeline(currentPage, { classifyTweets: false, }) } } async function tweakIndividualTweetPage() { userSortedReplies = false observeIndividualTweetTimeline(currentPage) if (config.replaceLogo) { (async () => { let $headingText = await getElement(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} h2 span`, { name: 'tweet thread heading', stopIf: pageIsNot(currentPage) }) if ($headingText && $headingText.textContent != getString('TWEET')) { $headingText.textContent = getString('TWEET') } })() } } function tweakListPage() { observeTimeline(currentPage, { hideHeadings: false, }) } async function tweakListsPage() { if (config.hideMoreTweets) { // Hide Discover new Lists let $showMoreLink = await getElement('a[href="/i/lists/suggested"]', { name: 'Show more link', stopIf: pageIsNot(currentPage), }) if (!$showMoreLink) return let $timelineItem = $showMoreLink.closest('[data-testid="cellInnerDiv"]') if (!$timelineItem) { warn('could not find timeline item containing Show more link') return } let $timelineItems = $timelineItem.parentElement.children let showMoreIndex = Array.prototype.indexOf.call($timelineItems, $timelineItem) for (let i = 1; i <= showMoreIndex + 2; i++) { $timelineItems[i].classList.add('SuggestedContent') } } } async function tweakDesktopLogo() { let $logoPath = await getElement(`h1 ${Selectors.X_LOGO_PATH}, h1 ${Selectors.X_DARUMA_LOGO_PATH}`, { name: 'desktop nav logo', timeout: 5000, }) if ($logoPath) { twitterLogo($logoPath) } } async function tweakHomeIcon() { let $homeIconPath = await getElement(`${Selectors.NAV_HOME_LINK} svg path`, {name: 'Home icon', stopIf: pageIsNot(currentPage)}) if ($homeIconPath) { homeIcon($homeIconPath) } } const tweakOwnFocusedTweet = (() => { let waitingForAnalyticsUpsell = false return async function tweakOwnFocusedTweet($focusedTweet) { // Only your own focused Tweets have an analytics button let $analyticsButton = $focusedTweet.querySelector('a[data-testid="analyticsButton"]') if (!$analyticsButton) return $analyticsButton.parentElement.classList.add('AnalyticsButton') if (!config.hideTwitterBlueUpsells || waitingForAnalyticsUpsell || $focusedTweet.getAttribute('data-upselltagged')) return waitingForAnalyticsUpsell = true try { let $accountAnalyticsUpsell = await getElement(':scope > div > div > div > div:has(a[href="/i/account_analytics"])', { context: $focusedTweet, name: 'account analytics upsell', timeout: 200, }) if ($accountAnalyticsUpsell) { $accountAnalyticsUpsell.classList.add('PremiumUpsell') $focusedTweet.setAttribute('data-upselltagged', 'true') } } finally { waitingForAnalyticsUpsell = false } } })() /** * Restores "Tweet" button text. */ async function tweakTweetButton() { let $tweetButton = await getElement(`${desktop ? 'div[data-testid="primaryColumn"]': 'main'} button[data-testid^="tweetButton"]`, { name: 'tweet button', stopIf: pageIsNot(currentPage), }) if ($tweetButton) { let $text = $tweetButton.querySelector('span > span') if ($text) { setTweetButtonText($text) } else { warn('could not find Tweet button text') } } } function tweakHomeTimelinePage() { let $timelineTabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`) // Hook for styling when on the separated tweets tab $body.classList.toggle('SeparatedTweets', isOnSeparatedTweetsTimeline()) if ($timelineTabs == null) { warn('could not find Home timeline tabs') return } tweakTimelineTabs($timelineTabs) if (mobile && isSafari && config.replaceLogo) { processTwitterLogos(document.querySelector(Selectors.MOBILE_TIMELINE_HEADER)) } function updateSelectedHomeTabIndex() { let $selectedHomeTabLink = $timelineTabs.querySelector('div[role="tablist"] a[aria-selected="true"]') if ($selectedHomeTabLink) { selectedHomeTabIndex = Array.from($selectedHomeTabLink.parentElement.parentElement.children).indexOf($selectedHomeTabLink.parentElement) log({selectedHomeTabIndex}) } else { warn('could not find selected Home tab link') selectedHomeTabIndex = -1 } } updateSelectedHomeTabIndex() // If there are pinned lists, the timeline tabs <nav> will be replaced when they load pageObservers.push( observeElement($timelineTabs.parentElement, (mutations) => { let timelineTabsReplaced = mutations.some(mutation => Array.from(mutation.removedNodes).includes($timelineTabs)) if (timelineTabsReplaced) { log('Home timeline tabs replaced') $timelineTabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`) tweakTimelineTabs($timelineTabs) } }, 'Home timeline tabs nav container') ) observeTimeline(currentPage, { isTabbed: true, onTabChanged: () => { updateSelectedHomeTabIndex() wasForYouTabSelected = selectedHomeTabIndex == 0 }, tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child', }) if (desktop) { observeDesktopHomeTimelineTweetBox() } } async function tweakMobileComposeTweetPage() { if (!config.replaceLogo && config.twitterBlueChecks == 'ignore') return function observeUserTypeaheadDropdown($tweetTextareaContainer) { if (!$tweetTextareaContainer) { warn('could not find Tweet textarea container to observe user dropdown') return } disconnectPageObserver('Tweet box typeahead dropdown container') disconnectPageObserver('Tweet box typeahead dropdown') let $dropdownContainer = $tweetTextareaContainer.parentElement.parentElement.parentElement.parentElement /** @type {HTMLElement} */ let $typeaheadDropdown = $dropdownContainer.querySelector(':scope > [id^="typeaheadDropdown"]') function observeDropdown() { pageObservers.push( observeElement($typeaheadDropdown, () => { processBlueChecks($typeaheadDropdown) }, 'Tweet box typeahead dropdown') ) } // If the list was re-rendered to display a dropdown for an additional // Tweet, it will already be in the DOM. if ($typeaheadDropdown) { observeDropdown() } pageObservers.push( observeElement($dropdownContainer, (mutations) => { for (let mutation of mutations) { if ($typeaheadDropdown && mutations.some(mutation => Array.from(mutation.removedNodes).includes($typeaheadDropdown))) { disconnectPageObserver('Tweet box typeahead dropdown') $typeaheadDropdown = null } for (let $addedNode of mutation.addedNodes) { if ($addedNode instanceof HTMLElement && $addedNode.getAttribute('id')?.startsWith('typeaheadDropdown')) { $typeaheadDropdown = $addedNode observeDropdown() } } } }, 'Tweet box typeahead dropdown container') ) } let isReply = Boolean(document.querySelector('article[data-testid="tweet"]')) if (isReply) { // Restore old placeholder in Tweet textarea if (config.replaceLogo) { let $textarea = /** @type {HTMLTextAreaElement} */ ( document.querySelector('main div[data-testid^="tweetTextarea"] textarea') ) if ($textarea) { $textarea.placeholder = getString('TWEET_YOUR_REPLY') } else { warn('could not find Tweet textarea') } } // Observe username typeahead dropdown in Tweet box if (config.twitterBlueChecks != 'ignore') { observeUserTypeaheadDropdown(document.querySelector('main div[data-testid^="tweetTextarea"]')) } } else { let $mask = document.querySelector('[data-testid="twc-cc-mask"]') let $tweetButtonText = document.querySelector('main button[data-testid^="tweetButton"] span > span') if ($mask && $tweetButtonText) { // We need to re-apply tweaks every time the child list changes. When // you use the username typeahead dropdown in any Tweet box, the list // re-renders so it's the only Tweet while the dropdown is open. observeElement($mask.nextElementSibling, () => { let $containers = document.querySelectorAll('main div[data-testid^="tweetTextarea"]') $containers.forEach(($container, index) => { if (config.replaceLogo) { let $textarea = $container.querySelector('textarea') $textarea.placeholder = getString(index == 0 ? 'WHATS_HAPPENING' : 'ADD_ANOTHER_TWEET') } if (index == 0 && config.twitterBlueChecks) { observeUserTypeaheadDropdown($container) } }) // Don't update the Tweet button if the list was re-rendered to display // a user dropdown, in which case it will already be in the DOM. if (config.replaceLogo && !document.querySelector('main [id^="typeaheadDropdown"]')) { $tweetButtonText.textContent = getString($containers.length == 1 ? 'TWEET' : 'TWEET_ALL') } }, 'Tweets container') } else { warn('could not find all elements needed to tweak the Compose Tweet page', {$mask, $tweetButtonText}) } } } async function tweakMobileMediaViewerPage() { let $timeline = await getElement('[data-testid="vss-scroll-view"] > div', { name: 'media viewer timeline', stopIf: () => !URL_MEDIAVIEWER_RE.test(location.pathname), }) if (!$timeline) return /** @param {HTMLVideoElement} $video */ function processVideo($video) { if ($video.loop != config.preventNextVideoAutoplay) { $video.loop = config.preventNextVideoAutoplay } } // Process initial contents let $videos = $timeline.querySelectorAll('video') log($videos.length, `initial video${s($videos.length)}`) $videos.forEach(processVideo) if (config.twitterBlueChecks != 'ignore') { processBlueChecks($timeline) } pageObservers.push( observeElement($timeline, (mutations) => { for (let mutation of mutations) { for (let $addedNode of mutation.addedNodes) { if (!($addedNode instanceof HTMLElement) || $addedNode.nodeName != 'DIV') continue let $video = $addedNode.querySelector('video') if ($video) { processVideo($video) } if (config.twitterBlueChecks != 'ignore') { let $videoInfo = $addedNode.querySelector('[data-testid^="immersive-tweet-ui-content-container"]') if ($videoInfo) { processBlueChecks($addedNode) } } } } }, 'media viewer timeline', {childList: true, subtree: true}) ) } async function tweakTimelineTabs($timelineTabs) { $timelineTabs.classList.add('TimelineTabs') let $followingTabLink = /** @type {HTMLElement} */ ($timelineTabs.querySelector('div[role="tablist"] > div:nth-child(2) > a')) if (config.alwaysUseLatestTweets && !document.title.startsWith(separatedTweetsTimelineTitle)) { let isForYouTabSelected = Boolean($timelineTabs.querySelector('div[role="tablist"] > div:first-child > a[aria-selected="true"]')) if (isForYouTabSelected && (!wasForYouTabSelected || config.hideForYouTimeline)) { log('switching to Following timeline') $followingTabLink.click() wasForYouTabSelected = false } else { wasForYouTabSelected = isForYouTabSelected } } if (shouldShowSeparatedTweetsTab()) { let $newTab = /** @type {HTMLElement} */ ($timelineTabs.querySelector('#tnt_separated_tweets_tab')) if ($newTab) { log('separated tweets timeline tab already present') $newTab.querySelector('span').textContent = separatedTweetsTimelineTitle } else { log('inserting separated tweets tab') $newTab = /** @type {HTMLElement} */ ($followingTabLink.parentElement.cloneNode(true)) $newTab.id = 'tnt_separated_tweets_tab' $newTab.querySelector('span').textContent = separatedTweetsTimelineTitle let $link = $newTab.querySelector('a') $link.removeAttribute('aria-selected') // This script assumes navigation has occurred when the document title // changes, so by changing the title we fake navigation to a non-existent // page representing the separated tweets timeline. $link.addEventListener('click', (e) => { e.preventDefault() e.stopPropagation() if (!document.title.startsWith(separatedTweetsTimelineTitle)) { // The separated tweets tab belongs to the Following tab let isFollowingTabSelected = Boolean($timelineTabs.querySelector('div[role="tablist"] > div:nth-child(2) > a[aria-selected="true"]')) if (!isFollowingTabSelected) { log('switching to the Following tab for separated tweets') $followingTabLink.click() } setTitle(separatedTweetsTimelineTitle) } window.scrollTo({top: 0}) }) $followingTabLink.parentElement.insertAdjacentElement('afterend', $newTab) // Return to the Home timeline when any other tab is clicked $followingTabLink.parentElement.parentElement.addEventListener('click', () => { if (location.pathname == '/home' && !document.title.startsWith(getString('HOME'))) { log('setting title to Home') homeNavigationIsBeingUsed = true setTitle(getString('HOME')) } }) // Return to the Home timeline when the Home nav link is clicked let $homeNavLink = await getElement(Selectors.NAV_HOME_LINK, { name: 'home nav link', stopIf: pathIsNot(currentPath), }) if ($homeNavLink && !$homeNavLink.dataset.tweakNewTwitterListener) { $homeNavLink.addEventListener('click', () => { homeNavigationIsBeingUsed = true if (location.pathname == '/home' && !document.title.startsWith(getString('HOME'))) { setTitle(getString('HOME')) } }) $homeNavLink.dataset.tweakNewTwitterListener = 'true' } } } else { removeMobileTimelineHeaderElements() } } function tweakNotificationsPage() { let $navigationTabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`) if ($navigationTabs == null) { warn('could not find Notifications tabs') return } if (config.hideVerifiedNotificationsTab) { let isVerifiedTabSelected = Boolean($navigationTabs.querySelector('div[role="tablist"] > div:nth-child(2) > a[aria-selected="true"]')) if (isVerifiedTabSelected) { log('switching to All tab') let $allTab = /** @type {HTMLAnchorElement} */ ( $navigationTabs.querySelector('div[role="tablist"] > div:nth-child(1) > a') ) $allTab?.click() } } if (config.twitterBlueChecks != 'ignore' || config.restoreLinkHeadlines) { observeTimeline(currentPage, { isTabbed: true, tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child', }) } } async function tweakProfilePage() { let $initialContent = await getElement(desktop ? Selectors.PRIMARY_COLUMN : Selectors.MOBILE_TIMELINE_HEADER, { name: 'initial profile content', stopIf: pageIsNot(currentPage), }) if (!$initialContent) return if (config.twitterBlueChecks != 'ignore') { processBlueChecks($initialContent) } let tab = currentPath.match(URL_PROFILE_RE)?.[2] || 'tweets' log(`on ${tab} tab`) observeTimeline(currentPage, { isUserTimeline: tab == 'affiliates' }) getElement('a[href="/settings/profile"]', { name: 'edit profile button', stopIf: pageIsNot(currentPage), timeout: 500, }).then($editProfileButton => { $body.classList.toggle('OwnProfile', Boolean($editProfileButton)) if (config.hideTwitterBlueUpsells) { // This selector is _extremely_ specific to try to avoid false positives getElement(mobile ? ( '[data-testid="primaryColumn"] > div > div > div > div > div > div > div > div > div:has(> div > div > div > a[href^="/i/premium"])' ) : ( '[data-testid="primaryColumn"] > div > div > div > div > div > div:has(> div > div > div > a[href^="/i/premium"])' ), { name: "you aren't verified yet premium upsell", stopIf: pageIsNot(currentPage), timeout: 200, }).then($upsell => { if ($upsell) { $upsell.classList.add('PremiumUpsell') } }) } }) let $headerVerifiedIcon = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.TIMELINE_HEADING} [data-testid="icon-verified"]`) $body.classList.toggle('PremiumProfile', Boolean($headerVerifiedIcon)) if (config.replaceLogo || config.hid###bscriptions) { let $profileTabs = await getElement(`${Selectors.PRIMARY_COLUMN} nav`, { name: 'profile tabs', stopIf: pageIsNot(currentPage), }) if (!$profileTabs) return // The Profile tabs <nav> can be replaced pageObservers.push( observeElement($profileTabs.parentElement, async (mutations) => { if (mutations.length > 0) { let $newProfileTabs = findAddedNode(mutations, ($el) => $el instanceof HTMLElement && $el.tagName == 'NAV') if ($newProfileTabs == null) return $profileTabs = /** @type {HTMLElement} */ ($newProfileTabs) } if (config.replaceLogo) { let $tweetsTabText = await getElement('[data-testid="ScrollSnap-List"] > [role="presentation"]:first-child div[dir] > span:first-child', { context: $profileTabs, name: 'Tweets tab text', stopIf: pageIsNot(currentPage), }) if ($tweetsTabText && $tweetsTabText.textContent != getString('TWEETS')) { $tweetsTabText.textContent = getString('TWEETS') } } if (config.hid###bscriptions) { let $subscriptionsTabLink = await getElement('a[href$="/superfollows"]', { context: $profileTabs, name: 'Subscriptions tab link', stopIf: pageIsNot(currentPage), timeout: 1000, }) if ($subscriptionsTabLink) { $subscriptionsTabLink.parentElement.classList.add('SubsTab') } } }, 'profile tabs', {childList: true}) ) } } /** * @param {Element} $dropdownItem * @param {string} dropdownItemSelector * @param {import("./types").LocaleKey} localeKey */ async function tweakRetweetDropdown($dropdownItem, dropdownItemSelector, localeKey) { log('tweaking Retweet/Quote Tweet dropdown') if (desktop) { $dropdownItem = await getElement(` #layers div[data-testid="Dropdown"] ${dropdownItemSelector} `, { name: 'rendered menu item', timeout: 100, }) if (!$dropdownItem) return } let $text = $dropdownItem.querySelector('div[dir] > span') if ($text) $text.textContent = getString(localeKey) let $quoteTweetText = $dropdownItem.nextElementSibling?.querySelector('div[dir] > span') if ($quoteTweetText) $quoteTweetText.textContent = getString('QUOTE_TWEET') } function tweakSearchPage() { let $searchTabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`) if ($searchTabs != null) { if (config.defaultToLatestSearch) { let isTopTabSelected = Boolean($searchTabs.querySelector('div[role="tablist"] > div:nth-child(1) > a[aria-selected="true"]')) if (isTopTabSelected) { log('switching to Latest tab') let $latestTab = /** @type {HTMLAnchorElement} */ ( $searchTabs.querySelector('div[role="tablist"] > div:nth-child(2) > a') ) $latestTab?.click() } } } else { warn('could not find Search tabs') } observeTimeline(currentPage, { hideHeadings: false, isTabbed: true, tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child', }) if (desktop) { let $emptyFirstSidebarItem = document.querySelector(`${Selectors.SIDEBAR_WRAPPERS} > div:first-child:empty`) if ($emptyFirstSidebarItem) { log('removing empty first sidebar item from Search sidebar') $emptyFirstSidebarItem.remove() } } } function tweakTweetEngagementPage() { if (config.replaceLogo) { let $headingText = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} h2 span`) if ($headingText) { if ($headingText.textContent != getString('TWEET_INTERACTIONS')) { $headingText.textContent = getString('TWEET_INTERACTIONS') } } else { warn('could not find Post engagement heading') } } let $tabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`) if ($tabs == null) { warn('could not find Post engagement tabs') return } if (tweetInteractionsTab) { log('switching to tab', tweetInteractionsTab) let $tab = /** @type {HTMLAnchorElement} */ ( $tabs.querySelector(`div[role="tablist"] > div:nth-child(${tweetInteractionsTab}) > a`) ) $tab?.click() tweetInteractionsTab = null } if (config.replaceLogo) { let $quoteTweetsTabText = $tabs.querySelector('div[role="tablist"] > div:nth-child(1) div[dir] > span') if ($quoteTweetsTabText) $quoteTweetsTabText.textContent = getString('QUOTE_TWEETS') let $retweetsTabText = $tabs.querySelector('div[role="tablist"] > div:nth-child(2) div[dir] > span') if ($retweetsTabText) $retweetsTabText.textContent = getString('RETWEETS') } if (config.twitterBlueChecks != 'ignore') { observeTimeline(currentPage, {classifyTweets: false}) } } //#endregion //#region Main async function main() { let $settings = /** @type {HTMLScriptElement} */ (document.querySelector('script#tnt_settings')) if ($settings) { try { Object.assign(config, JSON.parse($settings.innerText)) } catch(e) { error('error parsing initial settings', e) } let settingsChangeObserver = new MutationObserver(() => { /** @type {Partial<import("./types").Config>} */ let configChanges try { configChanges = JSON.parse($settings.innerText) } catch(e) { error('error parsing incoming settings change', e) return } if ('debug' in configChanges) { log('disabling debug mode') debug = configChanges.debug log('enabled debug mode') configureThemeCss() return } Object.assign(config, configChanges) configChanged(configChanges) }) settingsChangeObserver.observe($settings, {childList: true}) } if (config.debug) { debug = true } if (redirectToTwitter()) { return } observeTitle() observeFavicon() let $loadingStyle if (config.replaceLogo) { getElement('html', {name: 'html element'}).then(($html) => { $loadingStyle = document.createElement('style') $loadingStyle.dataset.insertedBy = 'control-panel-for-twitter' $loadingStyle.dataset.role = 'loading-logo' $loadingStyle.textContent = dedent(` ${Selectors.X_LOGO_PATH} { fill: ${isSafari ? 'transparent' : THEME_BLUE}; d: path("${Svgs.TWITTER_LOGO_PATH}"); } .tnt_logo { fill: ${THEME_BLUE}; } `) $html.appendChild($loadingStyle) }) if (isSafari) { getElement(Selectors.X_LOGO_PATH, {name: 'pre-loading indicator logo', timeout: 1000}).then(($logoPath) => { if ($logoPath) { twitterLogo($logoPath) } }) } } let $appWrapper = await getElement('#layers + div', {name: 'app wrapper'}) $html = document.querySelector('html') $body = document.body $reactRoot = document.querySelector('#react-root') lang = $html.lang dir = $html.dir ltr = dir == 'ltr' let lastFlexDirection observeElement($appWrapper, () => { let flexDirection = getComputedStyle($appWrapper).flexDirection mobile = flexDirection == 'column' desktop = !mobile /** @type {'mobile' | 'desktop'} */ let version = mobile ? 'mobile' : 'desktop' if (version != config.version) { log('setting version to', version) config.version = version // Let the options page know which version is being used storeConfigChanges({version}) } if (lastFlexDirection == null) { log('initial config', {config, lang, version}) // One-time setup checkReactNativeStylesheet() observeBodyBackgroundColor() let initialThemeColor = getThemeColorFromState() if (initialThemeColor) { themeColor = initialThemeColor } if (desktop) { fontSize = $html.style.fontSize if (!fontSize) { warn('initial fontSize not set on <html>') } } // Repeatable configuration setup configureSeparatedTweetsTimelineTitle() configureCss() configureDynamicCss() configureThemeCss() configureCustomCss() observePopups() observeSideNavTweetButton() // Start taking action on page changes observingPageChanges = true // Delay removing loading icon styles to avoid Flash of X if ($loadingStyle) { setTimeout(() => $loadingStyle.remove(), 1000) } } else if (flexDirection != lastFlexDirection) { configChanged({version}) } $body.classList.toggle('Mobile', mobile) $body.classList.toggle('Desktop', desktop) lastFlexDirection = flexDirection }, 'app wrapper class attribute for version changes (mobile ↔ desktop)', { attributes: true, attributeFilter: ['class'] }) } /** * @param {Partial<import("./types").Config>} changes */ function configChanged(changes) { log('config changed', changes) if ('redirectToTwitter' in changes && redirectToTwitter()) { return } if ('version' in changes) { fontSize = desktop ? $html.style.fontSize : null } configureCss() configureFont() configureDynamicCss() configureThemeCss() configureCustomCss() observePopups() observeSideNavTweetButton() if ('replaceLogo' in changes || 'hideNotifications' in changes) { observeFavicon.forceUpdate(getNotificationCount() > 0) } // Store the current notification count if hiding notifications was enabled if ('hideNotifications' in changes && config.hideNotifications != 'ignore') { hiddenNotificationCount = currentNotificationCount } let navigationTriggered = ( configureSeparatedTweetsTimelineTitle() || checkforDisabledHomeTimeline() ) if ('hideNotifications' in changes) { // Hide or show the notification count in the title. The title will already // have been updated if other navigation was triggered. if (!navigationTriggered) { setTitle(currentPage) navigationTriggered = true } // Clear the stored notification count if hiding notifications was disabled if (config.hideNotifications == 'ignore') { hiddenNotificationCount = '' } } // Only re-process the current page if navigation wasn't already triggered // while applying config changes. if (!navigationTriggered) { processCurrentPage() } } main() //#endregion }()