Control Panel for Twitter

Gives you more control over Twitter and adds missing features and UI improvements

  1. // ==UserScript==
  2. // @name Control Panel for Twitter
  3. // @description Gives you more control over Twitter and adds missing features and UI improvements
  4. // @icon https://raw.githubusercontent.com/insin/control-panel-for-twitter/master/icons/icon32.png
  5. // @namespace https://github.com/insin/control-panel-for-twitter/
  6. // @match https://twitter.com/*
  7. // @match https://mobile.twitter.com/*
  8. // @match https://x.com/*
  9. // @match https://mobile.x.com/*
  10. // @run-at document-start
  11. // @version 191
  12. // ==/UserScript==
  13. void function() {
  14.  
  15. // Patch XMLHttpRequest to modify requests
  16. const XMLHttpRequest_open = XMLHttpRequest.prototype.open
  17. XMLHttpRequest.prototype.open = function(method, url) {
  18. if (config.sortReplies != 'relevant' && !userSortedReplies && url.includes('/TweetDetail?')) {
  19. let request = new URL(url)
  20. let params = new URLSearchParams(request.search)
  21. let variables = JSON.parse(decodeURIComponent(params.get('variables')))
  22. variables.rankingMode = {
  23. liked: 'Likes',
  24. recent: 'Recency',
  25. }[config.sortReplies]
  26. params.set('variables', JSON.stringify(variables))
  27. url = `${request.origin}${request.pathname}?${params.toString()}`
  28. }
  29. return XMLHttpRequest_open.apply(this, [method, url])
  30. }
  31.  
  32. let debug = false
  33.  
  34. /** @type {boolean} */
  35. let desktop
  36. /** @type {boolean} */
  37. let mobile
  38. let isSafari = navigator.userAgent.includes('Safari/') && !/Chrom(e|ium)\//.test(navigator.userAgent)
  39.  
  40. /** @type {HTMLHtmlElement} */
  41. let $html
  42. /** @type {HTMLElement} */
  43. let $body
  44. /** @type {HTMLElement} */
  45. let $reactRoot
  46. /** @type {string} */
  47. let lang
  48. /** @type {string} */
  49. let dir
  50. /** @type {boolean} */
  51. let ltr
  52.  
  53. //#region Default config
  54. /**
  55. * @type {import("./types").Config}
  56. */
  57. const config = {
  58. debug: false,
  59. debugLogTimelineStats: false,
  60. // Shared
  61. addAddMutedWordMenuItem: true,
  62. alwaysUseLatestTweets: true,
  63. defaultToLatestSearch: false,
  64. disableHomeTimeline: false,
  65. disabledHomeTimelineRedirect: 'notifications',
  66. disableTweetTextFormatting: false,
  67. dontUseChirpFont: false,
  68. dropdownMenuFontWeight: true,
  69. fastBlock: true,
  70. followButtonStyle: 'monochrome',
  71. hideAdsNav: true,
  72. hideBlueReplyFollowedBy: false,
  73. hideBlueReplyFollowing: false,
  74. hideBookmarkButton: false,
  75. hideBookmarkMetrics: true,
  76. hideBookmarksNav: false,
  77. hideCommunitiesNav: false,
  78. hideComposeTweet: false,
  79. hideExplorePageContents: true,
  80. hideFollowingMetrics: true,
  81. hideForYouTimeline: true,
  82. hideGrokNav: true,
  83. hideGrokTweets: false,
  84. hideInlinePrompts: true,
  85. hideJobsNav: true,
  86. hideLikeMetrics: true,
  87. hideListsNav: false,
  88. hideMetrics: false,
  89. hideMonetizationNav: true,
  90. hideMoreTweets: true,
  91. hideNotifications: 'ignore',
  92. hideProfileRetweets: false,
  93. hideQuoteTweetMetrics: true,
  94. hideQuotesFrom: [],
  95. hideReplyMetrics: true,
  96. hideRetweetMetrics: true,
  97. hideSeeNewTweets: false,
  98. hideShareTweetButton: false,
  99. hideSubscriptions: true,
  100. hideTotalTweetsMetrics: true,
  101. hideTweetAnalyticsLinks: false,
  102. hideTwitterBlueReplies: false,
  103. hideTwitterBlueUpsells: true,
  104. hideUnavailableQuoteTweets: true,
  105. hideVerifiedNotificationsTab: true,
  106. hideViews: true,
  107. hideWhoToFollowEtc: true,
  108. listRetweets: 'ignore',
  109. mutableQuoteTweets: true,
  110. mutedQuotes: [],
  111. quoteTweets: 'ignore',
  112. redirectToTwitter: false,
  113. reducedInteractionMode: false,
  114. restoreLinkHeadlines: true,
  115. replaceLogo: true,
  116. restoreOtherInteractionLinks: false,
  117. restoreQuoteTweetsLink: true,
  118. retweets: 'separate',
  119. showBlueReplyFollowersCountAmount: '1000000',
  120. showBlueReplyFollowersCount: false,
  121. showBlueReplyVerifiedAccounts: false,
  122. showBookmarkButtonUnderFocusedTweets: true,
  123. sortReplies: 'relevant',
  124. tweakNewLayout: false,
  125. tweakQuoteTweetsPage: true,
  126. twitterBlueChecks: 'replace',
  127. unblurSensitiveContent: false,
  128. uninvertFollowButtons: true,
  129. // Experiments
  130. customCss: '',
  131. // Desktop only
  132. fullWidthContent: false,
  133. fullWidthMedia: true,
  134. hideAccountSwitcher: false,
  135. hideExploreNav: true,
  136. hideExploreNavWithSidebar: true,
  137. hideMessagesDrawer: true,
  138. hideProNav: true,
  139. hideSidebarContent: true,
  140. hideSpacesNav: false,
  141. hideTimelineTweetBox: false,
  142. hideToggleNavigation: false,
  143. navBaseFontSize: true,
  144. navDensity: 'default',
  145. showRelevantPeople: false,
  146. // Mobile only
  147. preventNextVideoAutoplay: true,
  148. hideMessagesBottomNavItem: false,
  149. }
  150. //#endregion
  151.  
  152. //#region Locales
  153. /**
  154. * @type {Record<string, import("./types").Locale>}
  155. */
  156. const locales = {
  157. 'ar-x-fm': {
  158. ADD_ANOTHER_TWEET: 'ضافة تغريدة أخرى',
  159. ADD_MUTED_WORD: 'اضافة كلمة مكتومة',
  160. GROK_ACTIONS: 'إجراءات Grok',
  161. HOME: 'الرئيسيّة',
  162. LIKES: 'الإعجابات',
  163. MOST_RELEVANT: 'الأكثر ملائمة',
  164. MUTE_THIS_CONVERSATION: 'كتم هذه المحادثه',
  165. POST_ALL: 'نشر الكل',
  166. POST_UNAVAILABLE: 'هذا المنشور غير متاح.',
  167. PROFILE_SUMMARY: 'ملخص الملف الشخصيّ',
  168. QUOTES: 'اقتباسات',
  169. QUOTE_TWEET: 'اقتباس التغريدة',
  170. QUOTE_TWEETS: 'تغريدات اقتباس',
  171. REPOST: 'إعادة النشر',
  172. REPOSTS: 'المنشورات المُعاد نشرها',
  173. RETWEET: 'إعادة التغريد',
  174. RETWEETED_BY: 'مُعاد تغريدها بواسطة',
  175. RETWEETS: 'إعادات التغريد',
  176. SHARED: 'مشترك',
  177. SHARED_TWEETS: 'التغريدات المشتركة',
  178. SHOW: 'إظهار',
  179. SHOW_MORE_REPLIES: 'عرض المزيد من الردود',
  180. SORT_REPLIES_BY: 'فرز الردود حسب',
  181. TURN_OFF_QUOTE_TWEETS: 'تعطيل تغريدات اقتباس',
  182. TURN_OFF_RETWEETS: 'تعطيل إعادة التغريد',
  183. TURN_ON_RETWEETS: 'تفعيل إعادة التغريد',
  184. TWEET: 'غرّدي',
  185. TWEETS: 'التغريدات',
  186. TWEET_ALL: 'تغريد الكل',
  187. TWEET_INTERACTIONS: 'تفاعلات التغريدة',
  188. TWEET_YOUR_REPLY: 'التغريد بردك',
  189. TWITTER: 'تويتر',
  190. UNDO_RETWEET: 'التراجع عن التغريدة',
  191. VIEW: 'عرض',
  192. WHATS_HAPPENING: 'ماذا يحدث؟',
  193. },
  194. ar: {
  195. ADD_ANOTHER_TWEET: 'ضافة تغريدة أخرى',
  196. ADD_MUTED_WORD: 'اضافة كلمة مكتومة',
  197. GROK_ACTIONS: 'إجراءات Grok',
  198. HOME: 'الرئيسيّة',
  199. LIKES: 'الإعجابات',
  200. MOST_RELEVANT: 'الأكثر ملائمة',
  201. MUTE_THIS_CONVERSATION: 'كتم هذه المحادثه',
  202. POST_ALL: 'نشر الكل',
  203. POST_UNAVAILABLE: 'هذا المنشور غير متاح.',
  204. PROFILE_SUMMARY: 'ملخص الملف الشخصيّ',
  205. QUOTE: 'اقتباس',
  206. QUOTES: 'اقتباسات',
  207. QUOTE_TWEET: 'اقتباس التغريدة',
  208. QUOTE_TWEETS: 'تغريدات اقتباس',
  209. REPOST: 'إعادة النشر',
  210. REPOSTS: 'المنشورات المُعاد نشرها',
  211. RETWEET: 'إعادة التغريد',
  212. RETWEETED_BY: 'مُعاد تغريدها بواسطة',
  213. RETWEETS: 'إعادات التغريد',
  214. SHARED: 'مشترك',
  215. SHARED_TWEETS: 'التغريدات المشتركة',
  216. SHOW: 'إظهار',
  217. SHOW_MORE_REPLIES: 'عرض المزيد من الردود',
  218. SORT_REPLIES_BY: 'فرز الردود حسب',
  219. TURN_OFF_QUOTE_TWEETS: 'تعطيل تغريدات اقتباس',
  220. TURN_OFF_RETWEETS: 'تعطيل إعادة التغريد',
  221. TURN_ON_RETWEETS: 'تفعيل إعادة التغريد',
  222. TWEET: 'تغريد',
  223. TWEETS: 'التغريدات',
  224. TWEET_ALL: 'تغريد الكل',
  225. TWEET_INTERACTIONS: 'تفاعلات التغريدة',
  226. TWEET_YOUR_REPLY: 'التغريد بردك',
  227. UNDO_RETWEET: 'التراجع عن التغريدة',
  228. VIEW: 'عرض',
  229. WHATS_HAPPENING: 'ماذا يحدث؟',
  230. },
  231. bg: {
  232. ADD_ANOTHER_TWEET: 'Добавяне на друг туит',
  233. ADD_MUTED_WORD: 'Добавяне на заглушена дума',
  234. GROK_ACTIONS: 'Действия, свързани с Grok',
  235. HOME: 'Начало',
  236. LIKES: 'Харесвания',
  237. MOST_RELEVANT: 'Най-подходящи',
  238. MUTE_THIS_CONVERSATION: 'Заглушаване на разговора',
  239. POST_ALL: 'Публикуване на всичко',
  240. POST_UNAVAILABLE: 'Тази публикация не е налична.',
  241. PROFILE_SUMMARY: 'Резюме на профила',
  242. QUOTE: 'Цитат',
  243. QUOTES: 'Цитати',
  244. QUOTE_TWEET: 'Цитиране на туита',
  245. QUOTE_TWEETS: 'Туитове с цитат',
  246. REPOST: 'Препубликуване',
  247. REPOSTS: 'Препубликувания',
  248. RETWEET: 'Ретуитване',
  249. RETWEETED_BY: 'Ретуитнат от',
  250. RETWEETS: 'Ретуитове',
  251. SHARED: 'Споделен',
  252. SHARED_TWEETS: 'Споделени туитове',
  253. SHOW: 'Показване',
  254. SHOW_MORE_REPLIES: 'Показване на още отговори',
  255. SORT_REPLIES_BY: 'Сортиране на отговорите',
  256. TURN_OFF_QUOTE_TWEETS: 'Изключване на туитове с цитат',
  257. TURN_OFF_RETWEETS: 'Изключване на ретуитовете',
  258. TURN_ON_RETWEETS: 'Включване на ретуитовете',
  259. TWEET: 'Туит',
  260. TWEETS: 'Туитове',
  261. TWEET_ALL: 'Туитване на всички',
  262. TWEET_INTERACTIONS: 'Интеракции с туит',
  263. TWEET_YOUR_REPLY: 'туит своя отговор',
  264. UNDO_RETWEET: 'Отмяна на ретуитването',
  265. VIEW: 'Преглед',
  266. WHATS_HAPPENING: 'Какво се случва?',
  267. },
  268. bn: {
  269. ADD_ANOTHER_TWEET: 'অন্য টুইট যোগ করুন',
  270. ADD_MUTED_WORD: 'নীরব করা শব্দ যোগ করুন',
  271. GROK_ACTIONS: 'Grok কার্যকলাপ',
  272. HOME: 'হোম',
  273. LIKES: 'পছন্দ',
  274. MOST_RELEVANT: 'সবচেয়ে প্রাসঙ্গিক',
  275. MUTE_THIS_CONVERSATION: 'এই কথা-বার্তা নীরব করুন',
  276. POST_ALL: 'সবকটি পোস্ট করুন',
  277. POST_UNAVAILABLE: 'এই পোস্টটি অনুপলভ্য।',
  278. PROFILE_SUMMARY: 'প্রোফাইল সারসংক্ষেপ',
  279. QUOTE: 'উদ্ধৃতি',
  280. QUOTES: 'উদ্ধৃতিগুলো',
  281. QUOTE_TWEET: 'টুইট উদ্ধৃত করুন',
  282. QUOTE_TWEETS: 'টুইট উদ্ধৃতিগুলো',
  283. REPOST: 'রিপোস্ট',
  284. REPOSTS: 'রিপোস্ট',
  285. RETWEET: 'পুনঃটুইট',
  286. RETWEETED_BY: 'পুনঃ টুইট করেছেন',
  287. RETWEETS: 'পুনঃটুইটগুলো',
  288. SHARED: 'ভাগ করা',
  289. SHARED_TWEETS: 'ভাগ করা টুইটগুলি',
  290. SHOW: 'দেখান',
  291. SHOW_MORE_REPLIES: 'আরও উত্তর দেখান',
  292. SORT_REPLIES_BY: 'উত্তরগুলো এই হিসাবে বাছুন',
  293. TURN_OFF_QUOTE_TWEETS: 'উদ্ধৃতি টুইটগুলি বন্ধ করুন',
  294. TURN_OFF_RETWEETS: 'পুনঃ টুইটগুলি বন্ধ করুন',
  295. TURN_ON_RETWEETS: 'পুনঃ টুইটগুলি চালু করুন',
  296. TWEET: 'টুইট',
  297. TWEETS: 'টুইটগুলি',
  298. TWEET_ALL: 'সব টুইট করুন',
  299. TWEET_INTERACTIONS: 'টুইট ইন্টারেকশন',
  300. TWEET_YOUR_REPLY: 'আপনার উত্তর টুইট করুন',
  301. TWITTER: 'টুইটার',
  302. UNDO_RETWEET: 'পুনঃ টুইট পুর্বাবস্থায় ফেরান',
  303. VIEW: 'দেখুন',
  304. WHATS_HAPPENING: 'কি খবর?',
  305. },
  306. ca: {
  307. ADD_ANOTHER_TWEET: 'Afegeix un altre tuit',
  308. ADD_MUTED_WORD: 'Afegeix una paraula silenciada',
  309. GROK_ACTIONS: 'Accions de Grok',
  310. HOME: 'Inici',
  311. LIKES: 'Agradaments',
  312. MOST_RELEVANT: 'El més rellevant',
  313. MUTE_THIS_CONVERSATION: 'Silencia la conversa',
  314. POST_ALL: 'Publica-ho tot',
  315. POST_UNAVAILABLE: 'Aquesta publicació no està disponible.',
  316. PROFILE_SUMMARY: 'Resum del perfil',
  317. QUOTE: 'Cita',
  318. QUOTES: 'Cites',
  319. QUOTE_TWEET: 'Cita el tuit',
  320. QUOTE_TWEETS: 'Tuits amb cita',
  321. REPOST: 'Republicació',
  322. REPOSTS: 'Republicacions',
  323. RETWEET: 'Retuit',
  324. RETWEETED_BY: 'Retuitat per',
  325. RETWEETS: 'Retuits',
  326. SHARED: 'Compartit',
  327. SHARED_TWEETS: 'Tuits compartits',
  328. SHOW: 'Mostra',
  329. SHOW_MORE_REPLIES: 'Mostra més respostes',
  330. SORT_REPLIES_BY: 'Ordena les respostes per',
  331. TURN_OFF_QUOTE_TWEETS: 'Desactiva els tuits amb cita',
  332. TURN_OFF_RETWEETS: 'Desactiva els retuits',
  333. TURN_ON_RETWEETS: 'Activa els retuits',
  334. TWEET: 'Tuita',
  335. TWEETS: 'Tuits',
  336. TWEET_ALL: 'Tuita-ho tot',
  337. TWEET_INTERACTIONS: 'Interaccions amb tuits',
  338. TWEET_YOUR_REPLY: 'Tuita la teva resposta',
  339. UNDO_RETWEET: 'Desfés el retuit',
  340. VIEW: 'Mostra',
  341. WHATS_HAPPENING: 'Què passa?',
  342. },
  343. cs: {
  344. ADD_ANOTHER_TWEET: 'Přidat další Tweet',
  345. ADD_MUTED_WORD: 'Přidat slovo na seznam skrytých slov',
  346. GROK_ACTIONS: 'Akce funkce Grok',
  347. HOME: 'Hlavní stránka',
  348. LIKES: 'Lajky',
  349. MOST_RELEVANT: 'Nejvíce související',
  350. MUTE_THIS_CONVERSATION: 'Skrýt tuto konverzaci',
  351. POST_ALL: 'Postovat vše',
  352. POST_UNAVAILABLE: 'Tento post není dostupný.',
  353. PROFILE_SUMMARY: 'Souhrn profilu',
  354. QUOTE: 'Citace',
  355. QUOTES: 'Citace',
  356. QUOTE_TWEET: 'Citovat Tweet',
  357. QUOTE_TWEETS: 'Tweety s citací',
  358. REPOSTS: 'Reposty',
  359. RETWEET: 'Retweetnout',
  360. RETWEETED_BY: 'Retweetnuto uživateli',
  361. RETWEETS: 'Retweety',
  362. SHARED: 'Sdílený',
  363. SHARED_TWEETS: 'Sdílené tweety',
  364. SHOW: 'Zobrazit',
  365. SHOW_MORE_REPLIES: 'Zobrazit další odpovědi',
  366. SORT_REPLIES_BY: 'Odpovědi roztřiďte podle',
  367. TURN_OFF_QUOTE_TWEETS: 'Vypnout tweety s citací',
  368. TURN_OFF_RETWEETS: 'Vypnout retweety',
  369. TURN_ON_RETWEETS: 'Zapnout retweety',
  370. TWEET: 'Tweetovat',
  371. TWEETS: 'Tweety',
  372. TWEET_ALL: 'Tweetnout vše',
  373. TWEET_INTERACTIONS: 'Tweetovat interakce',
  374. TWEET_YOUR_REPLY: 'Tweetujte svou odpověď',
  375. UNDO_RETWEET: 'Zrušit Retweet',
  376. VIEW: 'Zobrazit',
  377. WHATS_HAPPENING: 'Co se děje?',
  378. },
  379. da: {
  380. ADD_ANOTHER_TWEET: 'Tilføj endnu et Tweet',
  381. ADD_MUTED_WORD: 'Tilføj skjult ord',
  382. GROK_ACTIONS: 'Grok-handlinger',
  383. HOME: 'Forside',
  384. MOST_RELEVANT: 'Mest relevante',
  385. MUTE_THIS_CONVERSATION: 'Skjul denne samtale',
  386. POST_ALL: 'Post alle',
  387. POST_UNAVAILABLE: 'Denne post er ikke tilgængelig.',
  388. PROFILE_SUMMARY: 'Profilresumé',
  389. QUOTE: 'Citat',
  390. QUOTES: 'Citater',
  391. QUOTE_TWEET: 'Citér Tweet',
  392. QUOTE_TWEETS: 'Citat-Tweets',
  393. RETWEETED_BY: 'Retweetet af',
  394. SHARED: 'Delt',
  395. SHARED_TWEETS: 'Delte tweets',
  396. SHOW: 'Vis',
  397. SHOW_MORE_REPLIES: 'Vis flere svar',
  398. SORT_REPLIES_BY: 'Sortér svar efter',
  399. TURN_OFF_QUOTE_TWEETS: 'Slå Citat-Tweets fra',
  400. TURN_OFF_RETWEETS: 'Slå Retweets fra',
  401. TURN_ON_RETWEETS: 'Slå Retweets til',
  402. TWEET_ALL: 'Tweet alt',
  403. TWEET_INTERACTIONS: 'Tweet-interaktioner',
  404. TWEET_YOUR_REPLY: 'Tweet dit svar',
  405. UNDO_RETWEET: 'Fortryd Retweet',
  406. VIEW: 'Vis',
  407. WHATS_HAPPENING: 'Hvad sker der?',
  408. },
  409. de: {
  410. ADD_ANOTHER_TWEET: 'Weiteren Tweet hinzufügen',
  411. ADD_MUTED_WORD: 'Stummgeschaltetes Wort hinzufügen',
  412. GROK_ACTIONS: 'Grok-Aktionen',
  413. HOME: 'Startseite',
  414. LIKES: 'Gefällt mir',
  415. MOST_RELEVANT: 'Besonders relevant',
  416. MUTE_THIS_CONVERSATION: 'Diese Konversation stummschalten',
  417. POST_ALL: 'Alle posten',
  418. POST_UNAVAILABLE: 'Dieser Post ist nicht verfügbar.',
  419. PROFILE_SUMMARY: 'Kurzprofil',
  420. QUOTE: 'Zitat',
  421. QUOTES: 'Zitate',
  422. QUOTE_TWEET: 'Tweet zitieren',
  423. QUOTE_TWEETS: 'Zitierte Tweets',
  424. REPOST: 'Reposten',
  425. RETWEET: 'Retweeten',
  426. RETWEETED_BY: 'Retweetet von',
  427. SHARED: 'Geteilt',
  428. SHARED_TWEETS: 'Geteilte Tweets',
  429. SHOW: 'Anzeigen',
  430. SHOW_MORE_REPLIES: 'Mehr Antworten anzeigen',
  431. SORT_REPLIES_BY: 'Antworten sortieren nach',
  432. TURN_OFF_QUOTE_TWEETS: 'Zitierte Tweets ausschalten',
  433. TURN_OFF_RETWEETS: 'Retweets ausschalten',
  434. TURN_ON_RETWEETS: 'Retweets einschalten',
  435. TWEET: 'Twittern',
  436. TWEET_ALL: 'Alle twittern',
  437. TWEET_INTERACTIONS: 'Tweet-Interaktionen',
  438. TWEET_YOUR_REPLY: 'Twittere deine Antwort',
  439. UNDO_RETWEET: 'Retweet rückgängig machen',
  440. VIEW: 'Anzeigen',
  441. WHATS_HAPPENING: 'Was gibt’s Neues?',
  442. },
  443. el: {
  444. ADD_ANOTHER_TWEET: 'Προσθήκη άλλου Tweet',
  445. ADD_MUTED_WORD: 'Προσθήκη λέξης σε σίγαση',
  446. GROK_ACTIONS: 'Δράσεις Grok',
  447. HOME: 'Αρχική σελίδα',
  448. LIKES: '"Μου αρέσει"',
  449. MOST_RELEVANT: 'Πιο σχετική',
  450. MUTE_THIS_CONVERSATION: 'Σίγαση αυτής της συζήτησης',
  451. POST_ALL: 'Δημοσίευση όλων',
  452. POST_UNAVAILABLE: 'Αυτή η ανάρτηση δεν είναι διαθέσιμη.',
  453. PROFILE_SUMMARY: ' Περίληψη προφίλ',
  454. QUOTE: 'Παράθεση',
  455. QUOTES: 'Παραθέσεις',
  456. QUOTE_TWEET: 'Παράθεση Tweet',
  457. QUOTE_TWEETS: 'Tweet με παράθεση',
  458. REPOST: 'Αναδημοσίευση',
  459. REPOSTS: 'Αναδημοσιεύσεις',
  460. RETWEETED_BY: 'Έγινε Retweet από',
  461. RETWEETS: 'Retweet',
  462. SHARED: 'Κοινόχρηστο',
  463. SHARED_TWEETS: 'Κοινόχρηστα Tweets',
  464. SHOW: 'Εμφάνιση',
  465. SHOW_MORE_REPLIES: 'Εμφάνιση περισσότερων απαντήσεων',
  466. SORT_REPLIES_BY: 'Ταξινόμηση απαντήσεων κατά',
  467. TURN_OFF_QUOTE_TWEETS: 'Απενεργοποίηση των tweet με παράθεση',
  468. TURN_OFF_RETWEETS: 'Απενεργοποίηση των Retweet',
  469. TURN_ON_RETWEETS: 'Ενεργοποίηση των Retweet',
  470. TWEETS: 'Tweet',
  471. TWEET_ALL: 'Δημοσίευση όλων ως Tweet',
  472. TWEET_INTERACTIONS: 'Αλληλεπιδράσεις με tweet',
  473. TWEET_YOUR_REPLY: 'Στείλτε την απάντησή σας',
  474. UNDO_RETWEET: 'Αναίρεση Retweet',
  475. VIEW: 'Προβολή',
  476. WHATS_HAPPENING: 'Τι συμβαίνει;',
  477. },
  478. en: {
  479. ADD_ANOTHER_TWEET: 'Add another Tweet',
  480. ADD_MUTED_WORD: 'Add muted word',
  481. GROK_ACTIONS: 'Grok actions',
  482. HOME: 'Home',
  483. LIKES: 'Likes',
  484. MOST_RELEVANT: 'Most relevant',
  485. MUTE_THIS_CONVERSATION: 'Mute this conversation',
  486. POST_ALL: 'Post all',
  487. POST_UNAVAILABLE: 'This post is unavailable.',
  488. PROFILE_SUMMARY: 'Profile Summary',
  489. QUOTE: 'Quote',
  490. QUOTES: 'Quotes',
  491. QUOTE_TWEET: 'Quote Tweet',
  492. QUOTE_TWEETS: 'Quote Tweets',
  493. REPOST: 'Repost',
  494. REPOSTS: 'Reposts',
  495. RETWEET: 'Retweet',
  496. RETWEETED_BY: 'Retweeted by',
  497. RETWEETS: 'Retweets',
  498. SHARED: 'Shared',
  499. SHARED_TWEETS: 'Shared Tweets',
  500. SHOW: 'Show',
  501. SHOW_MORE_REPLIES: 'Show more replies',
  502. SORT_REPLIES_BY: 'Sort replies by',
  503. TURN_OFF_QUOTE_TWEETS: 'Turn off Quote Tweets',
  504. TURN_OFF_RETWEETS: 'Turn off Retweets',
  505. TURN_ON_RETWEETS: 'Turn on Retweets',
  506. TWEET: 'Tweet',
  507. TWEETS: 'Tweets',
  508. TWEET_ALL: 'Tweet all',
  509. TWEET_INTERACTIONS: 'Tweet interactions',
  510. TWEET_YOUR_REPLY: 'Tweet your reply',
  511. TWITTER: 'Twitter',
  512. UNDO_RETWEET: 'Undo Retweet',
  513. VIEW: 'View',
  514. WHATS_HAPPENING: "What's happening?",
  515. },
  516. es: {
  517. ADD_ANOTHER_TWEET: 'Agregar otro Tweet',
  518. ADD_MUTED_WORD: 'Añadir palabra silenciada',
  519. GROK_ACTIONS: 'Acciones de Grok',
  520. HOME: 'Inicio',
  521. LIKES: 'Me gusta',
  522. MOST_RELEVANT: 'Más relevantes',
  523. MUTE_THIS_CONVERSATION: 'Silenciar esta conversación',
  524. POST_ALL: 'Postear todo',
  525. POST_UNAVAILABLE: 'Este post no está disponible.',
  526. PROFILE_SUMMARY: 'Resumen del perfil',
  527. QUOTE: 'Cita',
  528. QUOTES: 'Citas',
  529. QUOTE_TWEET: 'Citar Tweet',
  530. QUOTE_TWEETS: 'Tweets citados',
  531. REPOST: 'Repostear',
  532. RETWEET: 'Retwittear',
  533. RETWEETED_BY: 'Retwitteado por',
  534. SHARED: 'Compartido',
  535. SHARED_TWEETS: 'Tweets compartidos',
  536. SHOW: 'Mostrar',
  537. SHOW_MORE_REPLIES: 'Mostrar más respuestas',
  538. SORT_REPLIES_BY: 'Ordenar respuestas por',
  539. TURN_OFF_QUOTE_TWEETS: 'Desactivar tweets citados',
  540. TURN_OFF_RETWEETS: 'Desactivar Retweets',
  541. TURN_ON_RETWEETS: 'Activar Retweets',
  542. TWEET: 'Twittear',
  543. TWEET_ALL: 'Twittear todo',
  544. TWEET_INTERACTIONS: 'Interacciones con Tweet',
  545. TWEET_YOUR_REPLY: 'Twittea tu respuesta',
  546. UNDO_RETWEET: 'Deshacer Retweet',
  547. VIEW: 'Ver',
  548. WHATS_HAPPENING: '¿Qué está pasando?',
  549. },
  550. eu: {
  551. ADD_ANOTHER_TWEET: 'Gehitu beste txio bat',
  552. ADD_MUTED_WORD: 'Gehitu isilarazitako hitza',
  553. HOME: 'Hasiera',
  554. LIKES: 'Atsegiteak',
  555. MUTE_THIS_CONVERSATION: 'Isilarazi elkarrizketa hau',
  556. QUOTE: 'Aipamena',
  557. QUOTES: 'Aipamenak',
  558. QUOTE_TWEET: 'Txioa apaitu',
  559. QUOTE_TWEETS: 'Aipatu txioak',
  560. RETWEET: 'Bertxiotu',
  561. RETWEETED_BY: 'Bertxiotua:',
  562. RETWEETS: 'Bertxioak',
  563. SHARED: 'Partekatua',
  564. SHARED_TWEETS: 'Partekatutako',
  565. SHOW: 'Erakutsi',
  566. SHOW_MORE_REPLIES: 'Erakutsi erantzun gehiago',
  567. TURN_OFF_QUOTE_TWEETS: 'Desaktibatu aipatu txioak',
  568. TURN_OFF_RETWEETS: 'Desaktibatu birtxioak',
  569. TURN_ON_RETWEETS: 'Aktibatu birtxioak',
  570. TWEET: 'Txio',
  571. TWEETS: 'Txioak',
  572. TWEET_ALL: 'Txiotu guztiak',
  573. TWEET_INTERACTIONS: 'Txio elkarrekintzak',
  574. TWEET_YOUR_REPLY: 'Txiotu zure erantzuna',
  575. UNDO_RETWEET: 'Desegin birtxiokatzea',
  576. VIEW: 'Ikusi',
  577. WHATS_HAPPENING: 'Zer gertatzen ari da?',
  578. },
  579. fa: {
  580. ADD_ANOTHER_TWEET: 'افزودن توییت دیگر',
  581. ADD_MUTED_WORD: 'افزودن واژه خموش‌سازی شده',
  582. GROK_ACTIONS: 'کنش‌های Grok',
  583. HOME: 'خانه',
  584. LIKES: 'پسندها',
  585. MOST_RELEVANT: 'مرتبط‌ترین',
  586. MUTE_THIS_CONVERSATION: 'خموش‌سازی این گفتگو',
  587. POST_ALL: 'پست کردن همه',
  588. POST_UNAVAILABLE: 'این پست دردسترس نیست.',
  589. PROFILE_SUMMARY: 'خلاصه نمایه',
  590. QUOTE: 'نقل‌قول',
  591. QUOTES: 'نقل‌قول‌ها',
  592. QUOTE_TWEET: 'نقل‌توییت',
  593. QUOTE_TWEETS: 'نقل‌توییت‌ها',
  594. REPOST: 'بازپست',
  595. REPOSTS: 'بازپست',
  596. RETWEET: 'بازتوییت',
  597. RETWEETED_BY: 'بازتوییت‌ شد توسط',
  598. RETWEETS: 'بازتوییت‌ها',
  599. SHARED: 'مشترک',
  600. SHARED_TWEETS: 'توییتهای مشترک',
  601. SHOW: 'نمایش',
  602. SHOW_MORE_REPLIES: 'نمایش پاسخ‌های بیشتر',
  603. SORT_REPLIES_BY: 'مرتب‌سازی پاسخ‌ها براساس',
  604. TURN_OFF_QUOTE_TWEETS: 'غیرفعال‌سازی نقل‌توییت‌ها',
  605. TURN_OFF_RETWEETS: 'غیرفعال‌سازی بازتوییت‌ها',
  606. TURN_ON_RETWEETS: 'فعال سازی بازتوییت‌ها',
  607. TWEET: 'توییت',
  608. TWEETS: 'توييت‌ها',
  609. TWEET_ALL: 'توییت به همه',
  610. TWEET_INTERACTIONS: 'تعاملات توییت',
  611. TWEET_YOUR_REPLY: 'پاسختان را توییت کنید',
  612. TWITTER: 'توییتر',
  613. UNDO_RETWEET: 'لغو بازتوییت',
  614. VIEW: 'مشاهده',
  615. WHATS_HAPPENING: 'چه خبر؟',
  616. },
  617. fi: {
  618. ADD_ANOTHER_TWEET: 'Lisää vielä twiitti',
  619. ADD_MUTED_WORD: 'Lisää hiljennetty sana',
  620. GROK_ACTIONS: 'Grok-toiminnat',
  621. HOME: 'Etusivu',
  622. LIKES: 'Tykkäykset',
  623. MOST_RELEVANT: 'Relevanteimmat',
  624. MUTE_THIS_CONVERSATION: 'Hiljennä tämä keskustelu',
  625. POST_ALL: 'Julkaise kaikki',
  626. POST_UNAVAILABLE: 'Tämä julkaisu ei ole saatavilla.',
  627. PROFILE_SUMMARY: 'Profiilin yhteenveto',
  628. QUOTE: 'Lainaa',
  629. QUOTES: 'Lainaukset',
  630. QUOTE_TWEET: 'Twiitin lainaus',
  631. QUOTE_TWEETS: 'Twiitin lainaukset',
  632. REPOST: 'Uudelleenjulkaise',
  633. REPOSTS: 'Uudelleenjulkaisut',
  634. RETWEET: 'Uudelleentwiittaa',
  635. RETWEETED_BY: 'Uudelleentwiitannut',
  636. RETWEETS: 'Uudelleentwiittaukset',
  637. SHARED: 'Jaettu',
  638. SHARED_TWEETS: 'Jaetut twiitit',
  639. SHOW: 'Näytä',
  640. SHOW_MORE_REPLIES: 'Näytä lisää vastauksia',
  641. SORT_REPLIES_BY: 'Vastausten lajittelutapa',
  642. TURN_OFF_QUOTE_TWEETS: 'Poista twiitin lainaukset käytöstä',
  643. TURN_OFF_RETWEETS: 'Poista uudelleentwiittaukset käytöstä',
  644. TURN_ON_RETWEETS: 'Ota uudelleentwiittaukset käyttöön',
  645. TWEET: 'Twiittaa',
  646. TWEETS: 'Twiitit',
  647. TWEET_ALL: 'Twiittaa kaikki',
  648. TWEET_INTERACTIONS: 'Twiitin vuorovaikutukset',
  649. TWEET_YOUR_REPLY: 'Twiittaa vastauksesi',
  650. UNDO_RETWEET: 'Kumoa uudelleentwiittaus',
  651. VIEW: 'Näytä',
  652. WHATS_HAPPENING: 'Missä mennään?',
  653. },
  654. fil: {
  655. ADD_ANOTHER_TWEET: 'Magdagdag ng isa pang Tweet',
  656. ADD_MUTED_WORD: 'Idagdag ang naka-mute na salita',
  657. GROK_ACTIONS: 'Mga aksyon ni Grok',
  658. LIKES: 'Mga Gusto',
  659. MOST_RELEVANT: 'Pinakanauugnay',
  660. MUTE_THIS_CONVERSATION: 'I-mute ang usapang ito',
  661. POST_ALL: 'I-post lahat',
  662. POST_UNAVAILABLE: 'Hindi available ang post na Ito.',
  663. PROFILE_SUMMARY: 'Buod ng Profile',
  664. QUOTES: 'Mga Quote',
  665. QUOTE_TWEET: 'Quote na Tweet',
  666. QUOTE_TWEETS: 'Mga Quote na Tweet',
  667. REPOST: 'I-repost',
  668. REPOSTS: '(na) Repost',
  669. RETWEET: 'I-retweet',
  670. RETWEETED_BY: 'Ni-retweet ni',
  671. RETWEETS: 'Mga Retweet',
  672. SHARED: 'Ibinahagi',
  673. SHARED_TWEETS: 'Mga Ibinahaging Tweet',
  674. SHOW: 'Ipakita',
  675. SHOW_MORE_REPLIES: 'Magpakita pa ng mga sagot',
  676. SORT_REPLIES_BY: 'I-sort ang mga reply batay sa',
  677. TURN_OFF_QUOTE_TWEETS: 'I-off ang mga Quote na Tweet',
  678. TURN_OFF_RETWEETS: 'I-off ang Retweets',
  679. TURN_ON_RETWEETS: 'I-on ang Retweets',
  680. TWEET: 'Mag-tweet',
  681. TWEETS: 'Mga Tweet',
  682. TWEET_ALL: 'I-tweet lahat',
  683. TWEET_INTERACTIONS: 'Interaksyon sa Tweet',
  684. TWEET_YOUR_REPLY: 'I-Tweet ang reply mo',
  685. UNDO_RETWEET: 'Huwag nang I-retweet',
  686. VIEW: 'Tingnan',
  687. WHATS_HAPPENING: 'Ano ang nangyayari?',
  688. },
  689. fr: {
  690. ADD_ANOTHER_TWEET: 'Ajouter un autre Tweet',
  691. ADD_MUTED_WORD: 'Ajouter un mot masqué',
  692. GROK_ACTIONS: 'Actions Grok',
  693. HOME: 'Accueil',
  694. LIKES: "J'aime",
  695. MOST_RELEVANT: 'Les plus pertinentes',
  696. MUTE_THIS_CONVERSATION: 'Masquer cette conversation',
  697. POST_ALL: 'Tout poster',
  698. POST_UNAVAILABLE: "Ce post n'est pas disponible.",
  699. PROFILE_SUMMARY: 'Résumé du profil',
  700. QUOTE: 'Citation',
  701. QUOTES: 'Citations',
  702. QUOTE_TWEET: 'Citer le Tweet',
  703. QUOTE_TWEETS: 'Tweets cités',
  704. RETWEET: 'Retweeter',
  705. RETWEETED_BY: 'Retweeté par',
  706. SHARED: 'Partagé',
  707. SHARED_TWEETS: 'Tweets partagés',
  708. SHOW: 'Afficher',
  709. SHOW_MORE_REPLIES: 'Voir plus de réponses',
  710. SORT_REPLIES_BY: 'Trier les réponses par',
  711. TURN_OFF_QUOTE_TWEETS: 'Désactiver les Tweets cités',
  712. TURN_OFF_RETWEETS: 'Désactiver les Retweets',
  713. TURN_ON_RETWEETS: 'Activer les Retweets',
  714. TWEET: 'Tweeter',
  715. TWEET_ALL: 'Tout tweeter',
  716. TWEET_INTERACTIONS: 'Interactions avec Tweet',
  717. TWEET_YOUR_REPLY: 'Tweetez votre réponse',
  718. UNDO_RETWEET: 'Annuler le Retweet',
  719. VIEW: 'Voir',
  720. WHATS_HAPPENING: 'Quoi de neuf !',
  721. },
  722. ga: {
  723. ADD_ANOTHER_TWEET: 'Cuir Tweet eile leis',
  724. ADD_MUTED_WORD: 'Cuir focal balbhaithe leis',
  725. HOME: 'Baile',
  726. LIKES: 'Thaitin siad seo le',
  727. MUTE_THIS_CONVERSATION: 'Balbhaigh an comhrá seo',
  728. QUOTE: 'Sliocht',
  729. QUOTES: 'Sleachta',
  730. QUOTE_TWEET: 'Cuir Ráiteas Leis',
  731. QUOTE_TWEETS: 'Luaigh Tvuíteanna',
  732. RETWEET: 'Atweetáil',
  733. RETWEETED_BY: 'Atweetáilte ag',
  734. RETWEETS: 'Atweetanna',
  735. SHARED: 'Roinnte',
  736. SHARED_TWEETS: 'Tweetanna Roinnte',
  737. SHOW: 'Taispeáin',
  738. SHOW_MORE_REPLIES: 'Taispeáin tuilleadh freagraí',
  739. TURN_OFF_QUOTE_TWEETS: 'Cas as Luaigh Tvuíteanna',
  740. TURN_OFF_RETWEETS: 'Cas as Atweetanna',
  741. TURN_ON_RETWEETS: 'Cas Atweetanna air',
  742. TWEETS: 'Tweetanna',
  743. TWEET_ALL: 'Tweetáil gach rud',
  744. TWEET_INTERACTIONS: 'Idirghníomhaíochtaí le Tweet',
  745. TWEET_YOUR_REPLY: 'Tweetáil do fhreagra',
  746. UNDO_RETWEET: 'Cuir an Atweet ar ceal',
  747. VIEW: 'Breathnaigh',
  748. WHATS_HAPPENING: 'Cad atá ag tarlú?',
  749. },
  750. gl: {
  751. ADD_ANOTHER_TWEET: 'Engadir outro chío',
  752. ADD_MUTED_WORD: 'Engadir palabra silenciada',
  753. HOME: 'Inicio',
  754. LIKES: 'Gústames',
  755. MUTE_THIS_CONVERSATION: 'Silenciar esta conversa',
  756. QUOTE: 'Cita',
  757. QUOTES: 'Citas',
  758. QUOTE_TWEET: 'Citar chío',
  759. QUOTE_TWEETS: 'Chíos citados',
  760. RETWEET: 'Rechouchiar',
  761. RETWEETED_BY: 'Rechouchiado por',
  762. RETWEETS: 'Rechouchíos',
  763. SHARED: 'Compartido',
  764. SHARED_TWEETS: 'Chíos compartidos',
  765. SHOW: 'Amosar',
  766. SHOW_MORE_REPLIES: 'Amosar máis respostas',
  767. TURN_OFF_QUOTE_TWEETS: 'Desactivar os chíos citados',
  768. TURN_OFF_RETWEETS: 'Desactivar os rechouchíos',
  769. TURN_ON_RETWEETS: 'Activar os rechouchíos',
  770. TWEET: 'Chío',
  771. TWEETS: 'Chíos',
  772. TWEET_ALL: 'Chiar todo',
  773. TWEET_INTERACTIONS: 'Interaccións chío',
  774. TWEET_YOUR_REPLY: 'Chío a túa responder',
  775. UNDO_RETWEET: 'Desfacer rechouchío',
  776. VIEW: 'Ver',
  777. WHATS_HAPPENING: 'Que está pasando?',
  778. },
  779. gu: {
  780. ADD_ANOTHER_TWEET: 'અન્ય ટ્વીટ ઉમેરો',
  781. ADD_MUTED_WORD: 'જોડાણ અટકાવેલો શબ્દ ઉમેરો',
  782. GROK_ACTIONS: 'Grok પગલાં',
  783. HOME: 'હોમ',
  784. LIKES: 'લાઈક્સ',
  785. MOST_RELEVANT: 'સૌથી વધુ સુસંગત',
  786. MUTE_THIS_CONVERSATION: 'આ વાર્તાલાપનું જોડાણ અટકાવો',
  787. POST_ALL: 'બધા પોસ્ટ કરો',
  788. POST_UNAVAILABLE: 'આ પોસ્ટ અનુપલબ્ધ છે.',
  789. PROFILE_SUMMARY: 'પ્રોફાઇલ સારાંશ',
  790. QUOTE: 'અવતરણ',
  791. QUOTES: 'અવતરણો',
  792. QUOTE_TWEET: 'અવતરણની સાથે ટ્વીટ કરો',
  793. QUOTE_TWEETS: 'અવતરણની સાથે ટ્વીટ્સ',
  794. REPOST: 'રીપોસ્ટ કરો',
  795. REPOSTS: 'ફરીથી કરવામાં આવેલી પોસ્ટ',
  796. RETWEET: 'પુનટ્વીટ',
  797. RETWEETED_BY: 'આમની દ્વારા પુનટ્વીટ કરવામાં આવી',
  798. RETWEETS: 'પુનટ્વીટ્સ',
  799. SHARED: 'સાંજેડેલું',
  800. SHARED_TWEETS: 'શેર કરેલી ટ્વીટ્સ',
  801. SHOW: 'બતાવો',
  802. SHOW_MORE_REPLIES: 'વધુ પ્રત્યુતરો દર્શાવો',
  803. SORT_REPLIES_BY: 'દ્વારા પ્રત્યુત્તરોને સૉર્ટ કરો',
  804. TURN_OFF_QUOTE_TWEETS: 'અવતરણની સાથે ટ્વીટ્સ બંધ કરો',
  805. TURN_OFF_RETWEETS: 'પુનટ્વીટ્સ બંધ કરો',
  806. TURN_ON_RETWEETS: 'પુનટ્વીટ્સ ચાલુ કરો',
  807. TWEET: 'ટ્વીટ',
  808. TWEETS: 'ટ્વીટ્સ',
  809. TWEET_ALL: 'બધાને ટ્વીટ કરો',
  810. TWEET_INTERACTIONS: 'ટ્વીટ ક્રિયાપ્રતિક્રિયાઓ',
  811. TWEET_YOUR_REPLY: 'તમારા પ્રત્યુત્તરને ટ્વીટ કરો',
  812. UNDO_RETWEET: 'પુનટ્વીટને પૂર્વવત કરો',
  813. VIEW: 'જુઓ',
  814. WHATS_HAPPENING: 'શું થઈ રહ્યું છે?',
  815. },
  816. he: {
  817. ADD_ANOTHER_TWEET: 'הוסף ציוץ נוסף',
  818. ADD_MUTED_WORD: 'הוסף מילה מושתקת',
  819. GROK_ACTIONS: 'פעולות של Grok',
  820. HOME: 'דף הבית',
  821. LIKES: 'הערות "אהבתי"',
  822. MOST_RELEVANT: 'הכי רלוונטי',
  823. MUTE_THIS_CONVERSATION: 'להשתיק את השיחה הזאת',
  824. POST_ALL: 'פרסום הכל',
  825. POST_UNAVAILABLE: 'פוסט זה אינו זמין.',
  826. PROFILE_SUMMARY: 'סיכום הפרופיל',
  827. QUOTE: 'ציטוט',
  828. QUOTES: 'ציטוטים',
  829. QUOTE_TWEET: 'ציטוט ציוץ',
  830. QUOTE_TWEETS: 'ציוצי ציטוט',
  831. REPOST: 'לפרסם מחדש',
  832. REPOSTS: 'פרסומים מחדש',
  833. RETWEET: 'צייץ מחדש',
  834. RETWEETED_BY: 'צויץ מחדש על־ידי',
  835. RETWEETS: 'ציוצים מחדש',
  836. SHARED: 'משותף',
  837. SHARED_TWEETS: 'ציוצים משותפים',
  838. SHOW: 'הצג',
  839. SHOW_MORE_REPLIES: 'הצג תשובות נוספות',
  840. SORT_REPLIES_BY: 'מיון תשובות לפי',
  841. TURN_OFF_QUOTE_TWEETS: 'כבה ציוצי ציטוט',
  842. TURN_OFF_RETWEETS: 'כבה ציוצים מחדש',
  843. TURN_ON_RETWEETS: 'הפעל ציוצים מחדש',
  844. TWEET: 'צייץ',
  845. TWEETS: 'ציוצים',
  846. TWEET_ALL: 'צייץ הכול',
  847. TWEET_INTERACTIONS: 'אינטראקציות צייץ',
  848. TWEET_YOUR_REPLY: 'צייץ התשובה',
  849. TWITTER: 'טוויטר',
  850. UNDO_RETWEET: 'ביטול ציוץ מחדש',
  851. VIEW: 'הצג',
  852. WHATS_HAPPENING: 'מה קורה?',
  853. },
  854. hi: {
  855. ADD_ANOTHER_TWEET: 'एक और ट्वीट जोड़ें',
  856. ADD_MUTED_WORD: 'म्यूट किया गया शब्द जोड़ें',
  857. GROK_ACTIONS: 'Grok कार्रवाई',
  858. HOME: 'होम',
  859. LIKES: 'पसंद',
  860. MOST_RELEVANT: 'सर्वाधिक प्रासंगिक',
  861. MUTE_THIS_CONVERSATION: 'इस बातचीत को म्यूट करें',
  862. POST_ALL: 'सभी पोस्ट करें',
  863. POST_UNAVAILABLE: 'यह पोस्ट उपलब्ध नहीं है.',
  864. PROFILE_SUMMARY: 'प्रोफ़ाइल सारांश',
  865. QUOTE: 'कोट',
  866. QUOTES: 'कोट',
  867. QUOTE_TWEET: 'ट्वीट क्वोट करें',
  868. QUOTE_TWEETS: 'कोट ट्वीट्स',
  869. REPOST: 'रीपोस्ट',
  870. REPOSTS: 'रीपोस्ट्स',
  871. RETWEET: 'रीट्वीट करें',
  872. RETWEETED_BY: 'इनके द्वारा रीट्वीट किया गया',
  873. RETWEETS: 'रीट्वीट्स',
  874. SHARED: 'साझा किया हुआ',
  875. SHARED_TWEETS: 'साझा किए गए ट्वीट',
  876. SHOW: 'दिखाएं',
  877. SHOW_MORE_REPLIES: 'और अधिक जवाब दिखाएँ',
  878. SORT_REPLIES_BY: 'से जवाब सॉर्ट करें',
  879. TURN_OFF_QUOTE_TWEETS: 'कोट ट्वीट्स बंद करें',
  880. TURN_OFF_RETWEETS: 'रीट्वीट बंद करें',
  881. TURN_ON_RETWEETS: 'रीट्वीट चालू करें',
  882. TWEET: 'ट्वीट करें',
  883. TWEETS: 'ट्वीट',
  884. TWEET_ALL: 'सभी ट्वीट करें',
  885. TWEET_INTERACTIONS: 'ट्वीट इंटरैक्शन',
  886. TWEET_YOUR_REPLY: 'अपना जवाब ट्वीट करें',
  887. UNDO_RETWEET: 'रीट्वीट को पूर्ववत करें',
  888. VIEW: 'देखें',
  889. WHATS_HAPPENING: 'क्या हो रहा है?',
  890. },
  891. hr: {
  892. ADD_ANOTHER_TWEET: 'Dodaj drugi Tweet',
  893. ADD_MUTED_WORD: 'Dodaj onemogućenu riječ',
  894. GROK_ACTIONS: 'Grokove radnje',
  895. HOME: 'Naslovnica',
  896. LIKES: 'Oznake „sviđa mi se”',
  897. MOST_RELEVANT: 'Najrelevantnije',
  898. MUTE_THIS_CONVERSATION: 'Isključi zvuk ovog razgovora',
  899. POST_ALL: 'Objavi sve',
  900. POST_UNAVAILABLE: 'Ta objava nije dostupna.',
  901. PROFILE_SUMMARY: 'Sažetak profila',
  902. QUOTE: 'Citat',
  903. QUOTES: 'Citati',
  904. QUOTE_TWEET: 'Citiraj Tweet',
  905. QUOTE_TWEETS: 'Citirani tweetovi',
  906. REPOST: 'Proslijedi objavu',
  907. REPOSTS: 'Proslijeđene objave',
  908. RETWEET: 'Proslijedi tweet',
  909. RETWEETED_BY: 'Korisnici koji su proslijedili Tweet',
  910. RETWEETS: 'Proslijeđeni tweetovi',
  911. SHARED: 'Podijeljeno',
  912. SHARED_TWEETS: 'Dijeljeni tweetovi',
  913. SHOW: 'Prikaži',
  914. SHOW_MORE_REPLIES: 'Prikaži još odgovora',
  915. SORT_REPLIES_BY: 'Sortiraj odgovore',
  916. TURN_OFF_QUOTE_TWEETS: 'Isključi citirane tweetove',
  917. TURN_OFF_RETWEETS: 'Isključi proslijeđene tweetove',
  918. TURN_ON_RETWEETS: 'Uključi proslijeđene tweetove',
  919. TWEETS: 'Tweetovi',
  920. TWEET_ALL: 'Tweetaj sve',
  921. TWEET_INTERACTIONS: 'Interakcije s Tweet',
  922. TWEET_YOUR_REPLY: 'Tweetajte odgovor',
  923. UNDO_RETWEET: 'Poništi prosljeđivanje tweeta',
  924. VIEW: 'Prikaz',
  925. WHATS_HAPPENING: 'Što se događa?',
  926. },
  927. hu: {
  928. ADD_ANOTHER_TWEET: 'Másik Tweet hozzáadása',
  929. ADD_MUTED_WORD: 'Elnémított szó hozzáadása',
  930. GROK_ACTIONS: 'Grok-műveletek',
  931. HOME: 'Kezdőlap',
  932. LIKES: 'Kedvelések',
  933. MOST_RELEVANT: 'Legmegfelelőbb',
  934. MUTE_THIS_CONVERSATION: 'Beszélgetés némítása',
  935. POST_ALL: 'Az összes közzététele',
  936. POST_UNAVAILABLE: 'Ez a bejegyzés nem elérhető.',
  937. PROFILE_SUMMARY: 'Profil összegzése',
  938. QUOTE: 'Idézés',
  939. QUOTES: 'Idézések',
  940. QUOTE_TWEET: 'Tweet idézése',
  941. QUOTE_TWEETS: 'Tweet-idézések',
  942. REPOST: 'Újraposztolás',
  943. REPOSTS: 'Újraposztolások',
  944. RETWEETED_BY: 'Retweetelte',
  945. RETWEETS: 'Retweetek',
  946. SHARED: 'Megosztott',
  947. SHARED_TWEETS: 'Megosztott tweetek',
  948. SHOW: 'Megjelenítés',
  949. SHOW_MORE_REPLIES: 'Több válasz megjelenítése',
  950. SORT_REPLIES_BY: 'Válaszok rendezése a következő szerint',
  951. TURN_OFF_QUOTE_TWEETS: 'Tweet-idézések kikapcsolása',
  952. TURN_OFF_RETWEETS: 'Retweetek kikapcsolása',
  953. TURN_ON_RETWEETS: 'Retweetek bekapcsolása',
  954. TWEET: 'Tweetelj',
  955. TWEETS: 'Tweetek',
  956. TWEET_ALL: 'Tweet küldése mindenkinek',
  957. TWEET_INTERACTIONS: 'Tweet interakciók',
  958. TWEET_YOUR_REPLY: 'Tweeteld válaszodat',
  959. UNDO_RETWEET: 'Retweet visszavonása',
  960. VIEW: 'Megtekintés',
  961. WHATS_HAPPENING: 'Mi történik éppen most?',
  962. },
  963. id: {
  964. ADD_ANOTHER_TWEET: 'Tambahkan Tweet lain',
  965. ADD_MUTED_WORD: 'Tambahkan kata kunci yang dibisukan',
  966. GROK_ACTIONS: 'Tindakan Grok',
  967. HOME: 'Beranda',
  968. LIKES: 'Suka',
  969. MOST_RELEVANT: 'Paling relevan',
  970. MUTE_THIS_CONVERSATION: 'Bisukan percakapan ini',
  971. POST_ALL: 'Posting semua',
  972. POST_UNAVAILABLE: 'Postingan ini tidak tersedia.',
  973. PROFILE_SUMMARY: 'Ringkasan Profil',
  974. QUOTE: 'Kutipan',
  975. QUOTES: 'Kutipan',
  976. QUOTE_TWEET: 'Kutip Tweet',
  977. QUOTE_TWEETS: 'Tweet Kutipan',
  978. REPOST: 'Posting ulang',
  979. REPOSTS: 'Posting ulang',
  980. RETWEETED_BY: 'Di-retweet oleh',
  981. RETWEETS: 'Retweet',
  982. SHARED: 'Dibagikan',
  983. SHARED_TWEETS: 'Tweet yang Dibagikan',
  984. SHOW: 'Tampilkan',
  985. SHOW_MORE_REPLIES: 'Tampilkan balasan lainnya',
  986. SORT_REPLIES_BY: 'Urutkan balasan berdasarkan',
  987. TURN_OFF_QUOTE_TWEETS: 'Matikan Tweet Kutipan',
  988. TURN_OFF_RETWEETS: 'Matikan Retweet',
  989. TURN_ON_RETWEETS: 'Nyalakan Retweet',
  990. TWEETS: 'Tweet',
  991. TWEET_ALL: 'Tweet semua',
  992. TWEET_INTERACTIONS: 'Interaksi Tweet',
  993. TWEET_YOUR_REPLY: 'Tweet balasan Anda',
  994. UNDO_RETWEET: 'Batalkan Retweet',
  995. VIEW: 'Lihat',
  996. WHATS_HAPPENING: 'Apa yang sedang hangat dibicarakan?',
  997. },
  998. it: {
  999. ADD_ANOTHER_TWEET: 'Aggiungi altro Tweet',
  1000. ADD_MUTED_WORD: 'Aggiungi parola o frase silenziata',
  1001. GROK_ACTIONS: 'Azioni di Grok',
  1002. LIKES: 'Mi piace',
  1003. MOST_RELEVANT: 'Più pertinenti',
  1004. MUTE_THIS_CONVERSATION: 'Silenzia questa conversazione',
  1005. POST_ALL: 'Posta tutto',
  1006. POST_UNAVAILABLE: 'Questo post non è disponibile.',
  1007. PROFILE_SUMMARY: 'Riepilogo del profilo',
  1008. QUOTE: 'Citazione',
  1009. QUOTES: 'Citazioni',
  1010. QUOTE_TWEET: 'Cita Tweet',
  1011. QUOTE_TWEETS: 'Tweet di citazione',
  1012. REPOSTS: 'Repost',
  1013. RETWEET: 'Ritwitta',
  1014. RETWEETED_BY: 'Ritwittato da',
  1015. RETWEETS: 'Retweet',
  1016. SHARED: 'Condiviso',
  1017. SHARED_TWEETS: 'Tweet condivisi',
  1018. SHOW: 'Mostra',
  1019. SHOW_MORE_REPLIES: 'Mostra altre risposte',
  1020. SORT_REPLIES_BY: 'Ordina risposte per',
  1021. TURN_OFF_QUOTE_TWEETS: 'Disattiva i Tweet di citazione',
  1022. TURN_OFF_RETWEETS: 'Disattiva Retweet',
  1023. TURN_ON_RETWEETS: 'Attiva Retweet',
  1024. TWEET: 'Twitta',
  1025. TWEETS: 'Tweet',
  1026. TWEET_ALL: 'Twitta tutto',
  1027. TWEET_INTERACTIONS: 'Interazioni con Tweet',
  1028. TWEET_YOUR_REPLY: 'Twitta la tua risposta',
  1029. UNDO_RETWEET: 'Annulla Retweet',
  1030. VIEW: 'Visualizza',
  1031. WHATS_HAPPENING: "Che c'è di nuovo?",
  1032. },
  1033. ja: {
  1034. ADD_ANOTHER_TWEET: '別のツイートを追加する',
  1035. ADD_MUTED_WORD: 'ミュートするキーワードを追加',
  1036. GROK_ACTIONS: 'Grokのアクション',
  1037. HOME: 'ホーム',
  1038. LIKES: 'いいね',
  1039. MOST_RELEVANT: '関連性が高い',
  1040. MUTE_THIS_CONVERSATION: 'この会話をミュート',
  1041. POST_ALL: 'すべてポスト',
  1042. POST_UNAVAILABLE: 'このポストは表示できません。',
  1043. PROFILE_SUMMARY: 'プロフィールの要約',
  1044. QUOTE: '引用',
  1045. QUOTES: '引用',
  1046. QUOTE_TWEET: '引用ツイート',
  1047. QUOTE_TWEETS: '引用ツイート',
  1048. REPOST: 'リポスト',
  1049. REPOSTS: 'リポスト',
  1050. RETWEET: 'リツイート',
  1051. RETWEETED_BY: 'リツイートしたユーザー',
  1052. RETWEETS: 'リツイート',
  1053. SHARED: '共有',
  1054. SHARED_TWEETS: '共有ツイート',
  1055. SHOW: '表示',
  1056. SHOW_MORE_REPLIES: '返信をさらに表示',
  1057. SORT_REPLIES_BY: '返信の並べ替え基準',
  1058. TURN_OFF_QUOTE_TWEETS: '引用ツイートをオフにする',
  1059. TURN_OFF_RETWEETS: 'リツイートをオフにする',
  1060. TURN_ON_RETWEETS: 'リツイートをオンにする',
  1061. TWEET: 'ツイートする',
  1062. TWEETS: 'ツイート',
  1063. TWEET_ALL: 'すべてツイート',
  1064. TWEET_INTERACTIONS: 'ツイートの相互作用',
  1065. TWEET_YOUR_REPLY: '返信をツイート',
  1066. UNDO_RETWEET: 'リツイートを取り消す',
  1067. VIEW: '表示する',
  1068. WHATS_HAPPENING: 'いまどうしてる?',
  1069. },
  1070. kn: {
  1071. ADD_ANOTHER_TWEET: 'ಮತ್ತೊಂದು ಟ್ವೀಟ್ ಸೇರಿಸಿ',
  1072. ADD_MUTED_WORD: 'ಸದ್ದಡಗಿಸಿದ ಪದವನ್ನು ಸೇರಿಸಿ',
  1073. GROK_ACTIONS: 'Grok ಕ್ರಮಗಳು',
  1074. HOME: 'ಹೋಮ್',
  1075. LIKES: 'ಇಷ್ಟಗಳು',
  1076. MOST_RELEVANT: 'ಅತ್ಯಂತ ಸಂಬಂಧಿತ',
  1077. MUTE_THIS_CONVERSATION: 'ಈ ಸಂವಾದವನ್ನು ಸದ್ದಡಗಿಸಿ',
  1078. POST_ALL: 'ಎಲ್ಲವನ್ನೂ ಪೋಸ್ಟ್ ಮಾಡಿ',
  1079. POST_UNAVAILABLE: 'ಈ ಪೋಸ್ಟ್ ಲಭ್ಯವಿಲ್ಲ.',
  1080. PROFILE_SUMMARY: 'ಪ್ರೊಫೈಲ್ ಸಾರಾಂಶ',
  1081. QUOTE: 'ಕೋಟ್‌',
  1082. QUOTES: 'ಉಲ್ಲೇಖಗಳು',
  1083. QUOTE_TWEET: 'ಟ್ವೀಟ್ ಕೋಟ್ ಮಾಡಿ',
  1084. QUOTE_TWEETS: 'ಕೋಟ್ ಟ್ವೀಟ್‌ಗಳು',
  1085. REPOST: 'ಮರುಪೋಸ್ಟ್ ಮಾಡಿ',
  1086. REPOSTS: 'ಮರುಪೋಸ್ಟ್‌ಗಳು',
  1087. RETWEET: 'ಮರುಟ್ವೀಟಿಸಿ',
  1088. RETWEETED_BY: 'ಮರುಟ್ವೀಟಿಸಿದವರು',
  1089. RETWEETS: 'ಮರುಟ್ವೀಟ್‌ಗಳು',
  1090. SHARED: 'ಹಂಚಲಾಗಿದೆ',
  1091. SHARED_TWEETS: 'ಹಂಚಿದ ಟ್ವೀಟ್‌ಗಳು',
  1092. SHOW: 'ತೋರಿಸಿ',
  1093. SHOW_MORE_REPLIES: 'ಇನ್ನಷ್ಟು ಪ್ರತಿಕ್ರಿಯೆಗಳನ್ನು ತೋರಿಸಿ',
  1094. SORT_REPLIES_BY: 'ಇದರ ಮೂಲಕ ಪ್ರತಿಕ್ರಿಯೆಗಳನ್ನು ಆಯೋಜಿಸಿ',
  1095. TURN_OFF_QUOTE_TWEETS: 'ಕೋಟ್ ಟ್ವೀಟ್‌ಗಳನ್ನು ಆಫ್ ಮಾಡಿ',
  1096. TURN_OFF_RETWEETS: 'ಮರುಟ್ವೀಟ್‌ಗಳನ್ನು ಆಫ್ ಮಾಡಿ',
  1097. TURN_ON_RETWEETS: 'ಮರುಟ್ವೀಟ್‌ಗಳನ್ನು ಆನ್ ಮಾಡಿ',
  1098. TWEET: 'ಟ್ವೀಟ್',
  1099. TWEETS: 'ಟ್ವೀಟ್‌ಗಳು',
  1100. TWEET_ALL: 'ಎಲ್ಲಾ ಟ್ವೀಟ್ ಮಾಡಿ',
  1101. TWEET_INTERACTIONS: 'ಟ್ವೀಟ್ ಸಂವಾದಗಳು',
  1102. TWEET_YOUR_REPLY: 'ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆಯನ್ನು ಟ್ವೀಟ್ ಮಾಡಿ',
  1103. UNDO_RETWEET: 'ಮರುಟ್ವೀಟಿಸುವುದನ್ನು ರದ್ದುಮಾಡಿ',
  1104. VIEW: 'ವೀಕ್ಷಿಸಿ',
  1105. WHATS_HAPPENING: 'ಏನು ನಡೆಯುತ್ತಿದೆ?',
  1106. },
  1107. ko: {
  1108. ADD_ANOTHER_TWEET: '다른 트윗 추가하기',
  1109. ADD_MUTED_WORD: '뮤트할 단어 추가하기',
  1110. GROK_ACTIONS: 'Grok 작업',
  1111. HOME: '홈',
  1112. LIKES: '마음에 들어요',
  1113. MOST_RELEVANT: '관련도 순서',
  1114. MUTE_THIS_CONVERSATION: '이 대화 뮤트하기',
  1115. POST_ALL: '모두 게시하기',
  1116. POST_UNAVAILABLE: '이 게시물을 볼 수 없습니다.',
  1117. PROFILE_SUMMARY: '프로필 요약',
  1118. QUOTE: '인용',
  1119. QUOTES: '인용',
  1120. QUOTE_TWEET: '트윗 인용하기',
  1121. QUOTE_TWEETS: '트윗 인용하기',
  1122. REPOST: '재게시',
  1123. REPOSTS: '재게시',
  1124. RETWEET: '리트윗',
  1125. RETWEETED_BY: '리트윗함',
  1126. RETWEETS: '리트윗',
  1127. SHARED: '공유된',
  1128. SHARED_TWEETS: '공유 트윗',
  1129. SHOW: '표시',
  1130. SHOW_MORE_REPLIES: '더 많은 답글 보기',
  1131. SORT_REPLIES_BY: '답글 정렬하기',
  1132. TURN_OFF_QUOTE_TWEETS: '인용 트윗 끄기',
  1133. TURN_OFF_RETWEETS: '리트윗 끄기',
  1134. TURN_ON_RETWEETS: '리트윗 켜기',
  1135. TWEET: '트윗',
  1136. TWEETS: '트윗',
  1137. TWEET_ALL: '모두 트윗하기',
  1138. TWEET_INTERACTIONS: '트윗 상호작용',
  1139. TWEET_YOUR_REPLY: '답글을 트윗하세요',
  1140. TWITTER: '트위터',
  1141. UNDO_RETWEET: '리트윗 취소',
  1142. VIEW: '보기',
  1143. WHATS_HAPPENING: '무슨 일이 일어나고 있나요?',
  1144. },
  1145. mr: {
  1146. ADD_ANOTHER_TWEET: 'दुसरे ट्विट सामील करा',
  1147. ADD_MUTED_WORD: 'म्यूट केलेले शब्द सामील करा',
  1148. GROK_ACTIONS: 'Grok कृती',
  1149. HOME: 'होम',
  1150. LIKES: 'पसंती',
  1151. MOST_RELEVANT: 'सर्वात महत्वाचे',
  1152. MUTE_THIS_CONVERSATION: 'ही चर्चा म्यूट करा',
  1153. POST_ALL: 'सर्व पोस्ट करा',
  1154. POST_UNAVAILABLE: 'हे पोस्ट अनुपलब्ध आहे.',
  1155. PROFILE_SUMMARY: 'प्रोफाइल सारांश',
  1156. QUOTE: 'भाष्य',
  1157. QUOTES: 'भाष्य',
  1158. QUOTE_TWEET: 'ट्विट वर भाष्य करा',
  1159. QUOTE_TWEETS: 'भाष्य ट्विट्स',
  1160. REPOST: 'पुन्हा पोस्ट करा',
  1161. REPOSTS: 'रिपोस्ट',
  1162. RETWEET: 'पुन्हा ट्विट',
  1163. RETWEETED_BY: 'यांनी पुन्हा ट्विट केले',
  1164. RETWEETS: 'पुनर्ट्विट्स',
  1165. SHARED: 'सामायिक',
  1166. SHARED_TWEETS: 'सामायिक ट्विट',
  1167. SHOW: 'दाखवा',
  1168. SHOW_MORE_REPLIES: 'अधिक प्रत्युत्तरे दाखवा',
  1169. SORT_REPLIES_BY: 'द्वारे प्रत्युत्तरांची क्रमवारी करा',
  1170. TURN_OFF_QUOTE_TWEETS: 'भाष्य ट्विट्स बंद करा',
  1171. TURN_OFF_RETWEETS: 'पुनर्ट्विट्स बंद करा',
  1172. TURN_ON_RETWEETS: 'पुनर्ट्विट्स चालू करा',
  1173. TWEET: 'ट्विट',
  1174. TWEETS: 'ट्विट्स',
  1175. TWEET_ALL: 'सर्व ट्विट करा',
  1176. TWEET_INTERACTIONS: 'ट्वीट इंटरऍक्शन्स',
  1177. TWEET_YOUR_REPLY: 'आपले प्रत्युत्तर ट्विट करा',
  1178. UNDO_RETWEET: 'पुनर्ट्विट पूर्ववत करा',
  1179. VIEW: 'पहा',
  1180. WHATS_HAPPENING: 'ताज्या घडामोडी?',
  1181. },
  1182. ms: {
  1183. ADD_ANOTHER_TWEET: 'Tambahkan Tweet lain',
  1184. ADD_MUTED_WORD: 'Tambahkan perkataan yang disenyapkan',
  1185. GROK_ACTIONS: 'Tindakan Grok',
  1186. HOME: 'Laman Utama',
  1187. LIKES: 'Suka',
  1188. MOST_RELEVANT: 'Paling berkaitan',
  1189. MUTE_THIS_CONVERSATION: 'Senyapkan perbualan ini',
  1190. POST_ALL: 'Siarkan semua',
  1191. POST_UNAVAILABLE: 'Siaran ini tidak tersedia.',
  1192. PROFILE_SUMMARY: 'Ringkasan Profil',
  1193. QUOTE: 'Petikan',
  1194. QUOTES: 'Petikan',
  1195. QUOTE_TWEET: 'Petik Tweet',
  1196. QUOTE_TWEETS: 'Tweet Petikan',
  1197. REPOST: 'Siaran semula',
  1198. REPOSTS: 'Siaran semula',
  1199. RETWEET: 'Tweet semula',
  1200. RETWEETED_BY: 'Ditweet semula oleh',
  1201. RETWEETS: 'Tweet semula',
  1202. SHARED: 'Dikongsi',
  1203. SHARED_TWEETS: 'Tweet Berkongsi',
  1204. SHOW: 'Tunjukkan',
  1205. SHOW_MORE_REPLIES: 'Tunjukkan lagi balasan',
  1206. SORT_REPLIES_BY: 'Isih balasan mengikut',
  1207. TURN_OFF_QUOTE_TWEETS: 'Matikan Tweet Petikan',
  1208. TURN_OFF_RETWEETS: 'Matikan Tweet semula',
  1209. TURN_ON_RETWEETS: 'Hidupkan Tweet semula',
  1210. TWEETS: 'Tweet',
  1211. TWEET_ALL: 'Tweet semua',
  1212. TWEET_INTERACTIONS: 'Interaksi Tweet',
  1213. TWEET_YOUR_REPLY: 'Tweet balasan anda',
  1214. UNDO_RETWEET: 'Buat asal Tweet semula',
  1215. VIEW: 'Lihat',
  1216. WHATS_HAPPENING: 'Apakah yang sedang berlaku?',
  1217. },
  1218. nb: {
  1219. ADD_ANOTHER_TWEET: 'Legg til en annen Tweet',
  1220. ADD_MUTED_WORD: 'Skjul nytt ord',
  1221. GROK_ACTIONS: 'Grok-handlinger',
  1222. HOME: 'Hjem',
  1223. LIKES: 'Liker',
  1224. MOST_RELEVANT: 'Mest relevante',
  1225. MUTE_THIS_CONVERSATION: 'Skjul denne samtalen',
  1226. POST_ALL: 'Publiser alle',
  1227. POST_UNAVAILABLE: 'Dette innlegget er utilgjengelig.',
  1228. PROFILE_SUMMARY: 'Profilsammendrag',
  1229. QUOTE: 'Sitat',
  1230. QUOTES: 'Sitater',
  1231. QUOTE_TWEET: 'Sitat-Tweet',
  1232. QUOTE_TWEETS: 'Sitat-Tweets',
  1233. REPOST: 'Republiser',
  1234. REPOSTS: 'Republiseringer',
  1235. RETWEETED_BY: 'Retweetet av',
  1236. SHARED: 'Delt',
  1237. SHARED_TWEETS: 'Delte tweets',
  1238. SHOW: 'Vis',
  1239. SHOW_MORE_REPLIES: 'Vis flere svar',
  1240. SORT_REPLIES_BY: 'Sorter svar etter',
  1241. TURN_OFF_QUOTE_TWEETS: 'Slå av sitat-tweets',
  1242. TURN_OFF_RETWEETS: 'Slå av Retweets',
  1243. TURN_ON_RETWEETS: 'Slå på Retweets',
  1244. TWEET_ALL: 'Tweet alle',
  1245. TWEET_INTERACTIONS: 'Tweet-interaksjoner',
  1246. TWEET_YOUR_REPLY: 'Tweet svaret ditt',
  1247. UNDO_RETWEET: 'Angre Retweet',
  1248. VIEW: 'Vis',
  1249. WHATS_HAPPENING: 'Hva skjer?',
  1250. },
  1251. nl: {
  1252. ADD_ANOTHER_TWEET: 'Nog een Tweet toevoegen',
  1253. ADD_MUTED_WORD: 'Genegeerd woord toevoegen',
  1254. GROK_ACTIONS: 'Grok-acties',
  1255. HOME: 'Startpagina',
  1256. LIKES: 'Vind-ik-leuks',
  1257. MOST_RELEVANT: 'Meest relevant',
  1258. MUTE_THIS_CONVERSATION: 'Dit gesprek negeren',
  1259. POST_ALL: 'Alles plaatsen',
  1260. POST_UNAVAILABLE: 'Deze post is niet beschikbaar.',
  1261. PROFILE_SUMMARY: 'Profieloverzicht',
  1262. QUOTE: 'Geciteerd',
  1263. QUOTES: 'Geciteerd',
  1264. QUOTE_TWEET: 'Citeer Tweet',
  1265. QUOTE_TWEETS: 'Geciteerde Tweets',
  1266. RETWEET: 'Retweeten',
  1267. RETWEETED_BY: 'Geretweet door',
  1268. SHARED: 'Gedeeld',
  1269. SHARED_TWEETS: 'Gedeelde Tweets',
  1270. SHOW: 'Weergeven',
  1271. SHOW_MORE_REPLIES: 'Meer antwoorden tonen',
  1272. SORT_REPLIES_BY: 'Antwoorden sorteren op',
  1273. TURN_OFF_QUOTE_TWEETS: 'Geciteerde Tweets uitschakelen',
  1274. TURN_OFF_RETWEETS: 'Retweets uitschakelen',
  1275. TURN_ON_RETWEETS: 'Retweets inschakelen',
  1276. TWEET: 'Tweeten',
  1277. TWEET_ALL: 'Alles tweeten',
  1278. TWEET_INTERACTIONS: 'Tweet-interacties',
  1279. TWEET_YOUR_REPLY: 'Tweet je antwoord',
  1280. UNDO_RETWEET: 'Retweet ongedaan maken',
  1281. VIEW: 'Bekijken',
  1282. WHATS_HAPPENING: 'Wat gebeurt er?',
  1283. },
  1284. pl: {
  1285. ADD_ANOTHER_TWEET: 'Dodaj kolejnego Tweeta',
  1286. ADD_MUTED_WORD: 'Dodaj wyciszone słowo',
  1287. GROK_ACTIONS: 'Akcje Groka',
  1288. HOME: 'Główna',
  1289. LIKES: 'Polubienia',
  1290. MOST_RELEVANT: 'Najtrafniejsze',
  1291. MUTE_THIS_CONVERSATION: 'Wycisz tę rozmowę',
  1292. POST_ALL: 'Opublikuj wszystko',
  1293. POST_UNAVAILABLE: 'Ten wpis jest niedostępny.',
  1294. PROFILE_SUMMARY: 'Podsumowanie profilu',
  1295. QUOTE: 'Cytuj',
  1296. QUOTES: 'Cytaty',
  1297. QUOTE_TWEET: 'Cytuj Tweeta',
  1298. QUOTE_TWEETS: 'Cytaty z Tweeta',
  1299. REPOST: 'Podaj dalej wpis',
  1300. REPOSTS: 'Wpisy podane dalej',
  1301. RETWEET: 'Podaj dalej',
  1302. RETWEETED_BY: 'Podane dalej przez',
  1303. RETWEETS: 'Tweety podane dalej',
  1304. SHARED: 'Udostępniony',
  1305. SHARED_TWEETS: 'Udostępnione Tweety',
  1306. SHOW: 'Pokaż',
  1307. SHOW_MORE_REPLIES: 'Pokaż więcej odpowiedzi',
  1308. SORT_REPLIES_BY: 'Sortuj odpowiedzi wg',
  1309. TURN_OFF_QUOTE_TWEETS: 'Wyłącz tweety z cytatem',
  1310. TURN_OFF_RETWEETS: 'Wyłącz Tweety podane dalej',
  1311. TURN_ON_RETWEETS: 'Włącz Tweety podane dalej',
  1312. TWEETS: 'Tweety',
  1313. TWEET_ALL: 'Tweetnij wszystko',
  1314. TWEET_INTERACTIONS: 'Interakcje na Tweeta',
  1315. TWEET_YOUR_REPLY: 'Tweeta swoją odpowiedź',
  1316. UNDO_RETWEET: 'Cofnij podanie dalej',
  1317. VIEW: 'Wyświetl',
  1318. WHATS_HAPPENING: 'Co się dzieje?',
  1319. },
  1320. pt: {
  1321. ADD_ANOTHER_TWEET: 'Adicionar outro Tweet',
  1322. ADD_MUTED_WORD: 'Adicionar palavra silenciada',
  1323. GROK_ACTIONS: 'Ações do Grok',
  1324. HOME: 'Página Inicial',
  1325. LIKES: 'Curtidas',
  1326. MOST_RELEVANT: 'Mais relevante',
  1327. MUTE_THIS_CONVERSATION: 'Silenciar esta conversa',
  1328. POST_ALL: 'Postar tudo',
  1329. POST_UNAVAILABLE: 'Este post está indisponível.',
  1330. PROFILE_SUMMARY: 'Resumo do perfil',
  1331. QUOTE: 'Comentar',
  1332. QUOTES: 'Comentários',
  1333. QUOTE_TWEET: 'Comentar o Tweet',
  1334. QUOTE_TWEETS: 'Tweets com comentário',
  1335. REPOST: 'Repostar',
  1336. RETWEET: 'Retweetar',
  1337. RETWEETED_BY: 'Retweetado por',
  1338. SHARED: 'Compartilhado',
  1339. SHARED_TWEETS: 'Tweets Compartilhados',
  1340. SHOW: 'Mostrar',
  1341. SHOW_MORE_REPLIES: 'Mostrar mais respostas',
  1342. SORT_REPLIES_BY: 'Ordenar respostas por',
  1343. TURN_OFF_QUOTE_TWEETS: 'Desativar Tweets com comentário',
  1344. TURN_OFF_RETWEETS: 'Desativar Retweets',
  1345. TURN_ON_RETWEETS: 'Ativar Retweets',
  1346. TWEET: 'Tweetar',
  1347. TWEET_ALL: 'Tweetar tudo',
  1348. TWEET_INTERACTIONS: 'Interações com Tweet',
  1349. TWEET_YOUR_REPLY: 'Tweetar sua resposta',
  1350. UNDO_RETWEET: 'Desfazer Retweet',
  1351. VIEW: 'Ver',
  1352. WHATS_HAPPENING: 'O que está acontecendo?',
  1353. },
  1354. ro: {
  1355. ADD_ANOTHER_TWEET: 'Adaugă alt Tweet',
  1356. ADD_MUTED_WORD: 'Adaugă cuvântul ignorat',
  1357. GROK_ACTIONS: 'Acțiuni Grok',
  1358. HOME: 'Pagina principală',
  1359. LIKES: 'Aprecieri',
  1360. MOST_RELEVANT: 'Cele mai relevante',
  1361. MUTE_THIS_CONVERSATION: 'Ignoră această conversație',
  1362. POST_ALL: 'Postează tot',
  1363. POST_UNAVAILABLE: 'Această postare este indisponibilă.',
  1364. PROFILE_SUMMARY: 'Sumarul profilului',
  1365. QUOTE: 'Citat',
  1366. QUOTES: 'Citate',
  1367. QUOTE_TWEET: 'Citează Tweetul',
  1368. QUOTE_TWEETS: 'Tweeturi cu citat',
  1369. REPOST: 'Repostează',
  1370. REPOSTS: 'Repostări',
  1371. RETWEET: 'Redistribuie',
  1372. RETWEETED_BY: 'Redistribuit de către',
  1373. RETWEETS: 'Retweeturi',
  1374. SHARED: 'Partajat',
  1375. SHARED_TWEETS: 'Tweeturi partajate',
  1376. SHOW: 'Afișează',
  1377. SHOW_MORE_REPLIES: 'Afișează mai multe răspunsuri',
  1378. SORT_REPLIES_BY: 'Sortare răspunsuri după',
  1379. TURN_OFF_QUOTE_TWEETS: 'Dezactivează tweeturile cu citat',
  1380. TURN_OFF_RETWEETS: 'Dezactivează Retweeturile',
  1381. TURN_ON_RETWEETS: 'Activează Retweeturile',
  1382. TWEETS: 'Tweeturi',
  1383. TWEET_ALL: 'Dă Tweeturi cu tot',
  1384. TWEET_INTERACTIONS: 'Interacțiuni cu Tweetul',
  1385. TWEET_YOUR_REPLY: 'Dă Tweet cu răspunsul',
  1386. UNDO_RETWEET: 'Anulează Retweetul',
  1387. VIEW: 'Vezi',
  1388. WHATS_HAPPENING: 'Ce se întâmplă?',
  1389. },
  1390. ru: {
  1391. ADD_ANOTHER_TWEET: 'Добавить еще один твит',
  1392. ADD_MUTED_WORD: 'Добавить игнорируемое слово',
  1393. GROK_ACTIONS: 'Действия Grok',
  1394. HOME: 'Главная',
  1395. LIKES: 'Нравится',
  1396. MOST_RELEVANT: 'Наиболее актуальные',
  1397. MUTE_THIS_CONVERSATION: 'Игнорировать эту переписку',
  1398. POST_ALL: 'Опубликовать все',
  1399. POST_UNAVAILABLE: 'Этот пост недоступен.',
  1400. PROFILE_SUMMARY: 'Сводка профиля',
  1401. QUOTE: 'Цитата',
  1402. QUOTES: 'Цитаты',
  1403. QUOTE_TWEET: 'Цитировать',
  1404. QUOTE_TWEETS: 'Твиты с цитатами',
  1405. REPOST: 'Сделать репост',
  1406. REPOSTS: 'Репосты',
  1407. RETWEET: 'Ретвитнуть',
  1408. RETWEETED_BY: 'Ретвитнул(а)',
  1409. RETWEETS: 'Ретвиты',
  1410. SHARED: 'Общий',
  1411. SHARED_TWEETS: 'Общие твиты',
  1412. SHOW: 'Показать',
  1413. SHOW_MORE_REPLIES: 'Показать ещё ответы',
  1414. SORT_REPLIES_BY: 'Упорядочить ответы по',
  1415. TURN_OFF_QUOTE_TWEETS: 'Отключить твиты с цитатами',
  1416. TURN_OFF_RETWEETS: 'Отключить ретвиты',
  1417. TURN_ON_RETWEETS: 'Включить ретвиты',
  1418. TWEET: 'Твитнуть',
  1419. TWEETS: 'Твиты',
  1420. TWEET_ALL: 'Твитнуть все',
  1421. TWEET_INTERACTIONS: 'Взаимодействие в Твитнуть',
  1422. TWEET_YOUR_REPLY: 'Твитните свой ответ',
  1423. TWITTER: 'Твиттер',
  1424. UNDO_RETWEET: 'Отменить ретвит',
  1425. VIEW: 'Посмотреть',
  1426. WHATS_HAPPENING: 'Что происходит?',
  1427. },
  1428. sk: {
  1429. ADD_ANOTHER_TWEET: 'Pridať ďalší Tweet',
  1430. ADD_MUTED_WORD: 'Pridať stíšené slovo',
  1431. GROK_ACTIONS: 'Akcie Groka',
  1432. HOME: 'Domov',
  1433. LIKES: 'Páči sa',
  1434. MOST_RELEVANT: 'Najrelevantnejšie',
  1435. MUTE_THIS_CONVERSATION: 'Stíšiť túto konverzáciu',
  1436. POST_ALL: 'Uverejniť všetko',
  1437. POST_UNAVAILABLE: 'Tento príspevok je nedostupný.',
  1438. PROFILE_SUMMARY: 'Súhrn profilu',
  1439. QUOTE: 'Citát',
  1440. QUOTES: 'Citáty',
  1441. QUOTE_TWEET: 'Tweet s citátom',
  1442. QUOTE_TWEETS: 'Tweety s citátom',
  1443. REPOST: 'Opätovné uverejnenie',
  1444. REPOSTS: 'Opätovné uverejnenia',
  1445. RETWEET: 'Retweetnuť',
  1446. RETWEETED_BY: 'Retweetnuté používateľom',
  1447. RETWEETS: 'Retweety',
  1448. SHARED: 'Zdieľaný',
  1449. SHARED_TWEETS: 'Zdieľané Tweety',
  1450. SHOW: 'Zobraziť',
  1451. SHOW_MORE_REPLIES: 'Zobraziť viac odpovedí',
  1452. SORT_REPLIES_BY: 'Zoradiť odpovede podľa',
  1453. TURN_OFF_QUOTE_TWEETS: 'Vypnúť tweety s citátom',
  1454. TURN_OFF_RETWEETS: 'Vypnúť retweety',
  1455. TURN_ON_RETWEETS: 'Zapnúť retweety',
  1456. TWEET: 'Tweetnuť',
  1457. TWEETS: 'Tweety',
  1458. TWEET_ALL: 'Tweetnuť všetko',
  1459. TWEET_INTERACTIONS: 'Interakcie s Tweet',
  1460. TWEET_YOUR_REPLY: 'Tweetnite odpoveď',
  1461. UNDO_RETWEET: 'Zrušiť retweet',
  1462. VIEW: 'Zobraziť',
  1463. WHATS_HAPPENING: 'Čo sa deje?',
  1464. },
  1465. sr: {
  1466. ADD_ANOTHER_TWEET: 'Додај још један твит',
  1467. ADD_MUTED_WORD: 'Додај игнорисану реч',
  1468. GROK_ACTIONS: 'Grok радње',
  1469. HOME: 'Почетна',
  1470. LIKES: 'Свиђања',
  1471. MOST_RELEVANT: 'Најважније',
  1472. MUTE_THIS_CONVERSATION: 'Игнориши овај разговор',
  1473. POST_ALL: 'Објави све',
  1474. POST_UNAVAILABLE: 'Ова објава није доступна.',
  1475. PROFILE_SUMMARY: 'Резиме профила',
  1476. QUOTE: 'Цитат',
  1477. QUOTES: 'Цитати',
  1478. QUOTE_TWEET: 'твит са цитатом',
  1479. QUOTE_TWEETS: 'твит(ов)а са цитатом',
  1480. REPOST: 'Поново објави',
  1481. REPOSTS: 'Понвне објаве',
  1482. RETWEET: 'Ретвитуј',
  1483. RETWEETED_BY: 'Ретвитовано од стране',
  1484. RETWEETS: 'Ретвитови',
  1485. SHARED: 'Подељено',
  1486. SHARED_TWEETS: 'Дељени твитови',
  1487. SHOW: 'Прикажи',
  1488. SHOW_MORE_REPLIES: 'Прикажи још одговора',
  1489. SORT_REPLIES_BY: 'Сортирај одговоре по',
  1490. TURN_OFF_QUOTE_TWEETS: 'Искључи твит(ов)е са цитатом',
  1491. TURN_OFF_RETWEETS: 'Искључи ретвитове',
  1492. TURN_ON_RETWEETS: 'Укључи ретвитове',
  1493. TWEET: 'Твитуј',
  1494. TWEETS: 'Твитови',
  1495. TWEET_ALL: 'Твитуј све',
  1496. TWEET_INTERACTIONS: 'Интеракције са Твитуј',
  1497. TWEET_YOUR_REPLY: 'Твитуј свој одговор',
  1498. TWITTER: 'Твитер',
  1499. UNDO_RETWEET: 'Опозови ретвит',
  1500. VIEW: 'Погледај',
  1501. WHATS_HAPPENING: 'Шта се дешава?',
  1502. },
  1503. sv: {
  1504. ADD_ANOTHER_TWEET: 'Lägg till en Tweet till',
  1505. ADD_MUTED_WORD: 'Lägg till ignorerat ord',
  1506. GROK_ACTIONS: 'Grok-åtgärder',
  1507. HOME: 'Hem',
  1508. LIKES: 'Gilla-markeringar',
  1509. MOST_RELEVANT: 'Mest relevant',
  1510. MUTE_THIS_CONVERSATION: 'Ignorera den här konversationen',
  1511. POST_ALL: 'Lägg upp allt',
  1512. POST_UNAVAILABLE: 'Detta inlägg är inte tillgängligt.',
  1513. PROFILE_SUMMARY: 'Profilöversikt',
  1514. QUOTE: 'Citat',
  1515. QUOTES: 'Citat',
  1516. QUOTE_TWEET: 'Citera Tweet',
  1517. QUOTE_TWEETS: 'Citat-tweets',
  1518. REPOST: 'Återpublicera',
  1519. REPOSTS: 'Återpubliceringar',
  1520. RETWEET: 'Retweeta',
  1521. RETWEETED_BY: 'Retweetad av',
  1522. SHARED: 'Delad',
  1523. SHARED_TWEETS: 'Delade tweetsen',
  1524. SHOW: 'Visa',
  1525. SHOW_MORE_REPLIES: 'Visa fler svar',
  1526. SORT_REPLIES_BY: 'Sortera svar på',
  1527. TURN_OFF_QUOTE_TWEETS: 'Stäng av citat-tweets',
  1528. TURN_OFF_RETWEETS: 'Stäng av Retweets',
  1529. TURN_ON_RETWEETS: 'Slå på Retweets',
  1530. TWEET: 'Tweeta',
  1531. TWEET_ALL: 'Tweeta allt',
  1532. TWEET_INTERACTIONS: 'Interaktioner med Tweet',
  1533. TWEET_YOUR_REPLY: 'Tweeta ditt svar',
  1534. UNDO_RETWEET: 'Ångra retweeten',
  1535. VIEW: 'Visa',
  1536. WHATS_HAPPENING: 'Vad är det som händer?',
  1537. },
  1538. ta: {
  1539. ADD_ANOTHER_TWEET: 'வேறொரு கீச்சைச் சேர்',
  1540. ADD_MUTED_WORD: 'செயல்மறைத்த வார்த்தையைச் சேர்',
  1541. GROK_ACTIONS: 'Grok செயல்கள்',
  1542. HOME: 'முகப்பு',
  1543. LIKES: 'விருப்பங்கள்',
  1544. MOST_RELEVANT: 'மிகவும் தொடர்புடையவை',
  1545. MUTE_THIS_CONVERSATION: 'இந்த உரையாடலை செயல்மறை',
  1546. POST_ALL: 'எல்லாம் இடுகையிடு',
  1547. POST_UNAVAILABLE: 'இந்த இடுகை கிடைக்கவில்லை.',
  1548. PROFILE_SUMMARY: 'சுயவிவரச் சுருக்கம்',
  1549. QUOTE: 'மேற்கோள்',
  1550. QUOTES: 'மேற்கோள்கள்',
  1551. QUOTE_TWEET: 'ட்விட்டை மேற்கோள் காட்டு',
  1552. QUOTE_TWEETS: 'மேற்கோள் கீச்சுகள்',
  1553. REPOST: 'மறுஇடுகை',
  1554. REPOSTS: 'மறுஇடுகைகள்',
  1555. RETWEET: 'மறுட்விட் செய்',
  1556. RETWEETED_BY: 'இவரால் மறுட்விட் செய்யப்பட்டது',
  1557. RETWEETS: 'மறுகீச்சுகள்',
  1558. SHARED: 'பகிரப்பட்டது',
  1559. SHARED_TWEETS: 'பகிரப்பட்ட ட்வீட்டுகள்',
  1560. SHOW: 'காண்பி',
  1561. SHOW_MORE_REPLIES: 'மேலும் பதில்களைக் காண்பி',
  1562. SORT_REPLIES_BY: 'இதன்படி பதில்களை வகைப்படுத்து',
  1563. TURN_OFF_QUOTE_TWEETS: 'மேற்கோள் கீச்சுகளை அணை',
  1564. TURN_OFF_RETWEETS: 'மறுகீச்சுகளை அணை',
  1565. TURN_ON_RETWEETS: 'மறுகீச்சுகளை இயக்கு',
  1566. TWEET: 'ட்விட் செய்',
  1567. TWEETS: 'கீச்சுகள்',
  1568. TWEET_ALL: 'அனைத்தையும் ட்விட் செய்',
  1569. TWEET_INTERACTIONS: 'ட்விட் செய் ஊடாடல்களைக்',
  1570. TWEET_YOUR_REPLY: 'உங்கள் பதிலை ட்விட் செய்யவும்',
  1571. UNDO_RETWEET: 'மறுகீச்சை செயல்தவிர்',
  1572. VIEW: 'காண்பி',
  1573. WHATS_HAPPENING: 'என்ன நிகழ்கிறது?',
  1574. },
  1575. th: {
  1576. ADD_ANOTHER_TWEET: 'เพิ่มอีกทวีต',
  1577. ADD_MUTED_WORD: 'เพิ่มคำที่ซ่อน',
  1578. GROK_ACTIONS: 'การดำเนินการของ Grok',
  1579. HOME: 'หน้าแรก',
  1580. LIKES: 'ความชอบ',
  1581. MOST_RELEVANT: 'เกี่ยวข้องที่สุด',
  1582. MUTE_THIS_CONVERSATION: 'ซ่อนบทสนทนานี้',
  1583. POST_ALL: 'โพสต์ทั้งหมด',
  1584. POST_UNAVAILABLE: 'โพสต์นี้ไม่สามารถใช้งานได้',
  1585. PROFILE_SUMMARY: 'ข้อมูลส่วนตัวโดยย่อ',
  1586. QUOTE: 'การอ้างอิง',
  1587. QUOTES: 'คำพูด',
  1588. QUOTE_TWEET: 'อ้างอิงทวีต',
  1589. QUOTE_TWEETS: 'ทวีตและคำพูด',
  1590. REPOST: 'รีโพสต์',
  1591. REPOSTS: 'รีโพสต์',
  1592. RETWEET: 'รีทวีต',
  1593. RETWEETED_BY: 'ถูกรีทวีตโดย',
  1594. RETWEETS: 'รีทวีต',
  1595. SHARED: 'แบ่งปัน',
  1596. SHARED_TWEETS: 'ทวีตที่แชร์',
  1597. SHOW: 'แสดง',
  1598. SHOW_MORE_REPLIES: 'แสดงการตอบกลับเพิ่มเติม',
  1599. SORT_REPLIES_BY: 'จัดเรียงการตอบกลับโดย',
  1600. TURN_OFF_QUOTE_TWEETS: 'ปิดทวีตและคำพูด',
  1601. TURN_OFF_RETWEETS: 'ปิดรีทวีต',
  1602. TURN_ON_RETWEETS: 'เปิดรีทวีต',
  1603. TWEET: 'ทวีต',
  1604. TWEETS: 'ทวีต',
  1605. TWEET_ALL: 'ทวีตทั้งหมด',
  1606. TWEET_INTERACTIONS: 'การโต้ตอบของทวีต',
  1607. TWEET_YOUR_REPLY: 'ทวีตการตอบกลับของคุณ',
  1608. TWITTER: 'ทวิตเตอร์',
  1609. UNDO_RETWEET: 'ยกเลิกการรีทวีต',
  1610. VIEW: 'ดู',
  1611. WHATS_HAPPENING: 'มีอะไรเกิดขึ้นบ้าง',
  1612. },
  1613. tr: {
  1614. ADD_ANOTHER_TWEET: 'Başka bir Tweet ekle',
  1615. ADD_MUTED_WORD: 'Sessize alınacak kelime ekle',
  1616. GROK_ACTIONS: 'Grok işlemleri',
  1617. HOME: 'Anasayfa',
  1618. LIKES: 'Beğeni',
  1619. MOST_RELEVANT: 'En alakalı',
  1620. MUTE_THIS_CONVERSATION: 'Bu sohbeti sessize al',
  1621. POST_ALL: 'Tümünü gönder',
  1622. POST_UNAVAILABLE: 'Bu gönderi kullanılamıyor.',
  1623. PROFILE_SUMMARY: 'Profil Özeti',
  1624. QUOTE: 'Alıntı',
  1625. QUOTES: 'Alıntılar',
  1626. QUOTE_TWEET: 'Tweeti Alıntıla',
  1627. QUOTE_TWEETS: 'Alıntı Tweetler',
  1628. REPOST: 'Yeniden gönder',
  1629. REPOSTS: 'Yeniden gönderiler',
  1630. RETWEETED_BY: 'Retweetleyen(ler):',
  1631. RETWEETS: 'Retweetler',
  1632. SHARED: 'Paylaşılan',
  1633. SHARED_TWEETS: 'Paylaşılan Tweetler',
  1634. SHOW: 'Göster',
  1635. SHOW_MORE_REPLIES: 'Daha fazla yanıt göster',
  1636. SORT_REPLIES_BY: 'Yanıtları sıralama ölçütü',
  1637. TURN_OFF_QUOTE_TWEETS: 'Alıntı Tweetleri kapat',
  1638. TURN_OFF_RETWEETS: 'Retweetleri kapat',
  1639. TURN_ON_RETWEETS: 'Retweetleri aç',
  1640. TWEET: 'Tweetle',
  1641. TWEETS: 'Tweetler',
  1642. TWEET_ALL: 'Hepsini Tweetle',
  1643. TWEET_INTERACTIONS: 'Tweet etkileşimleri',
  1644. TWEET_YOUR_REPLY: 'Yanıtını Tweetle',
  1645. UNDO_RETWEET: 'Retweeti Geri Al',
  1646. VIEW: 'Görüntüle',
  1647. WHATS_HAPPENING: 'Neler oluyor?',
  1648. },
  1649. uk: {
  1650. ADD_ANOTHER_TWEET: 'Додати ще один твіт',
  1651. ADD_MUTED_WORD: 'Додати слово до списку ігнорування',
  1652. GROK_ACTIONS: 'Дії Grok',
  1653. HOME: 'Головна',
  1654. LIKES: 'Вподобання',
  1655. MOST_RELEVANT: 'Найактуальніші',
  1656. MUTE_THIS_CONVERSATION: 'Ігнорувати цю розмову',
  1657. POST_ALL: 'Опублікувати все',
  1658. POST_UNAVAILABLE: 'Цей пост недоступний.',
  1659. PROFILE_SUMMARY: 'Зведення профілю',
  1660. QUOTE: 'Цитата',
  1661. QUOTES: 'Цитати',
  1662. QUOTE_TWEET: 'Цитувати твіт',
  1663. QUOTE_TWEETS: 'Цитовані твіти',
  1664. REPOST: 'Зробити репост',
  1665. REPOSTS: 'Репости',
  1666. RETWEET: 'Ретвітнути',
  1667. RETWEETED_BY: 'Ретвіти',
  1668. RETWEETS: 'Ретвіти',
  1669. SHARED: 'Спільний',
  1670. SHARED_TWEETS: 'Спільні твіти',
  1671. SHOW: 'Показати',
  1672. SHOW_MORE_REPLIES: 'Показати більше відповідей',
  1673. SORT_REPLIES_BY: 'Сортувати відповіді за',
  1674. TURN_OFF_QUOTE_TWEETS: 'Вимкнути цитовані твіти',
  1675. TURN_OFF_RETWEETS: 'Вимкнути ретвіти',
  1676. TURN_ON_RETWEETS: 'Увімкнути ретвіти',
  1677. TWEET: 'Твіт',
  1678. TWEETS: 'Твіти',
  1679. TWEET_ALL: 'Твітнути все',
  1680. TWEET_INTERACTIONS: 'Взаємодія твітів',
  1681. TWEET_YOUR_REPLY: 'Твітніть відповідь',
  1682. TWITTER: 'Твіттер',
  1683. UNDO_RETWEET: 'Скасувати ретвіт',
  1684. VIEW: 'Переглянути',
  1685. WHATS_HAPPENING: 'Що відбувається?',
  1686. },
  1687. ur: {
  1688. ADD_ANOTHER_TWEET: 'ایک اور ٹویٹ شامل کریں',
  1689. ADD_MUTED_WORD: 'میوٹ شدہ لفظ شامل کریں',
  1690. HOME: 'ہوم',
  1691. LIKES: 'لائک',
  1692. MUTE_THIS_CONVERSATION: 'اس گفتگو کو میوٹ کریں',
  1693. QUOTE: 'نقل کریں',
  1694. QUOTES: 'منقول',
  1695. QUOTE_TWEET: 'ٹویٹ کا حوالہ دیں',
  1696. QUOTE_TWEETS: 'ٹویٹ کو نقل کرو',
  1697. RETWEET: 'ریٹویٹ',
  1698. RETWEETED_BY: 'جنہوں نے ریٹویٹ کیا',
  1699. RETWEETS: 'ریٹویٹس',
  1700. SHARED: 'مشترکہ',
  1701. SHARED_TWEETS: 'مشترکہ ٹویٹس',
  1702. SHOW: 'دکھائیں',
  1703. SHOW_MORE_REPLIES: 'مزید جوابات دکھائیں',
  1704. TURN_OFF_QUOTE_TWEETS: 'ٹویٹ کو نقل کرنا بند کریں',
  1705. TURN_OFF_RETWEETS: 'ری ٹویٹس غیر فعال کریں',
  1706. TURN_ON_RETWEETS: 'ری ٹویٹس غیر فعال کریں',
  1707. TWEET: 'ٹویٹ',
  1708. TWEETS: 'ٹویٹس',
  1709. TWEET_ALL: 'سب کو ٹویٹ کریں',
  1710. TWEET_INTERACTIONS: 'ٹویٹ تعاملات',
  1711. TWEET_YOUR_REPLY: 'اپنا جواب ٹویٹ کریں',
  1712. TWITTER: 'ٹوئٹر',
  1713. UNDO_RETWEET: 'ری ٹویٹ کو کالعدم کریں',
  1714. VIEW: 'دیکھیں',
  1715. WHATS_HAPPENING: 'کیا ہو رہا ہے؟',
  1716. },
  1717. vi: {
  1718. ADD_ANOTHER_TWEET: 'Thêm Tweet khác',
  1719. ADD_MUTED_WORD: 'Thêm từ tắt tiếng',
  1720. GROK_ACTIONS: 'Hành động của Grok',
  1721. HOME: 'Trang chủ',
  1722. LIKES: 'Lượt thích',
  1723. MOST_RELEVANT: 'Liên quan nhất',
  1724. MUTE_THIS_CONVERSATION: 'Tắt tiếng cuộc trò chuyện này',
  1725. POST_ALL: 'Đăng tất cả',
  1726. POST_UNAVAILABLE: 'Không có bài đăng này.',
  1727. PROFILE_SUMMARY: 'Tóm tắt hồ sơ',
  1728. QUOTE: 'Trích dẫn',
  1729. QUOTES: 'Trích dẫn',
  1730. QUOTE_TWEET: 'Trích dẫn Tweet',
  1731. QUOTE_TWEETS: 'Tweet trích dẫn',
  1732. REPOST: 'Đăng lại',
  1733. REPOSTS: 'Bài đăng lại',
  1734. RETWEET: 'Tweet lại',
  1735. RETWEETED_BY: 'Được Tweet lại bởi',
  1736. RETWEETS: 'Các Tweet lại',
  1737. SHARED: 'Đã chia sẻ',
  1738. SHARED_TWEETS: 'Tweet được chia sẻ',
  1739. SHOW: 'Hiện',
  1740. SHOW_MORE_REPLIES: 'Hiển thị thêm trả lời',
  1741. SORT_REPLIES_BY: 'Sắp xếp câu trả lời theo',
  1742. TURN_OFF_QUOTE_TWEETS: 'Tắt Tweet trích dẫn',
  1743. TURN_OFF_RETWEETS: 'Tắt Tweet lại',
  1744. TURN_ON_RETWEETS: 'Bật Tweet lại',
  1745. TWEETS: 'Tweet',
  1746. TWEET_ALL: 'Đăng Tweet tất cả',
  1747. TWEET_INTERACTIONS: 'Tương tác Tweet',
  1748. TWEET_YOUR_REPLY: 'Đăng Tweet câu trả lời của bạn',
  1749. UNDO_RETWEET: 'Hoàn tác Tweet lại',
  1750. VIEW: 'Xem',
  1751. WHATS_HAPPENING: 'Chuyện gì đang xảy ra?',
  1752. },
  1753. 'zh-Hant': {
  1754. ADD_ANOTHER_TWEET: '加入另一則推文',
  1755. ADD_MUTED_WORD: '加入靜音文字',
  1756. GROK_ACTIONS: 'Grok 動作',
  1757. HOME: '首頁',
  1758. LIKES: '喜歡的內容',
  1759. MOST_RELEVANT: '最相關',
  1760. MUTE_THIS_CONVERSATION: '將此對話靜音',
  1761. POST_ALL: '全部發佈',
  1762. POST_UNAVAILABLE: '此貼文無法查看。',
  1763. PROFILE_SUMMARY: '個人檔案摘要',
  1764. QUOTE: '引用',
  1765. QUOTES: '引用',
  1766. QUOTE_TWEET: '引用推文',
  1767. QUOTE_TWEETS: '引用的推文',
  1768. REPOST: '轉發',
  1769. REPOSTS: '轉發',
  1770. RETWEET: '轉推',
  1771. RETWEETED_BY: '已被轉推',
  1772. RETWEETS: '轉推',
  1773. SHARED: '共享',
  1774. SHARED_TWEETS: '分享的推文',
  1775. SHOW: '顯示',
  1776. SHOW_MORE_REPLIES: '顯示更多回覆',
  1777. SORT_REPLIES_BY: '回覆排序方式',
  1778. TURN_OFF_QUOTE_TWEETS: '關閉引用的推文',
  1779. TURN_OFF_RETWEETS: '關閉轉推',
  1780. TURN_ON_RETWEETS: '開啟轉推',
  1781. TWEET: '推文',
  1782. TWEETS: '推文',
  1783. TWEET_ALL: '推全部內容',
  1784. TWEET_INTERACTIONS: '推文互動',
  1785. TWEET_YOUR_REPLY: '推你的回覆',
  1786. UNDO_RETWEET: '取消轉推',
  1787. VIEW: '查看',
  1788. WHATS_HAPPENING: '有什麼新鮮事?',
  1789. },
  1790. zh: {
  1791. ADD_ANOTHER_TWEET: '添加另一条推文',
  1792. ADD_MUTED_WORD: '添加要隐藏的字词',
  1793. GROK_ACTIONS: 'Grok 操作',
  1794. HOME: '主页',
  1795. LIKES: '喜欢',
  1796. MOST_RELEVANT: '最相关',
  1797. MUTE_THIS_CONVERSATION: '隐藏此对话',
  1798. POST_ALL: '全部发帖',
  1799. POST_UNAVAILABLE: '这个帖子不可用。',
  1800. PROFILE_SUMMARY: '个人资料概要',
  1801. QUOTE: '引用',
  1802. QUOTES: '引用',
  1803. QUOTE_TWEET: '引用推文',
  1804. QUOTE_TWEETS: '引用推文',
  1805. REPOST: '转帖',
  1806. REPOSTS: '转帖',
  1807. RETWEET: '转推',
  1808. RETWEETED_BY: '转推者',
  1809. RETWEETS: '转推',
  1810. SHARED: '共享',
  1811. SHARED_TWEETS: '分享的推文',
  1812. SHOW: '显示',
  1813. SHOW_MORE_REPLIES: '显示更多回复',
  1814. SORT_REPLIES_BY: '回复排序依据',
  1815. TURN_OFF_QUOTE_TWEETS: '关闭引用推文',
  1816. TURN_OFF_RETWEETS: '关闭转推',
  1817. TURN_ON_RETWEETS: '开启转推',
  1818. TWEET: '推文',
  1819. TWEETS: '推文',
  1820. TWEET_ALL: '全部发推',
  1821. TWEET_INTERACTIONS: '推文互动',
  1822. TWEET_YOUR_REPLY: '发布你的回复',
  1823. UNDO_RETWEET: '撤销转推',
  1824. VIEW: '查看',
  1825. WHATS_HAPPENING: '有什么新鲜事?',
  1826. },
  1827. }
  1828.  
  1829. /**
  1830. * @param {import("./types").LocaleKey} code
  1831. * @returns {string}
  1832. */
  1833. function getString(code) {
  1834. return (locales[lang] || locales['en'])[code] || locales['en'][code];
  1835. }
  1836. //#endregion
  1837.  
  1838. //#region Constants
  1839. /** @enum {string} */
  1840. const PagePaths = {
  1841. ACCESSIBILITY_SETTINGS: '/settings/accessibility',
  1842. ADD_MUTED_WORD: '/settings/add_muted_keyword',
  1843. BOOKMARKS: '/i/bookmarks',
  1844. COMPOSE_TWEET: '/compose/post',
  1845. CONNECT: '/i/connect',
  1846. DISPLAY_SETTINGS: '/settings/display',
  1847. HOME: '/home',
  1848. NOTIFICATION_TIMELINE: '/i/timeline',
  1849. PROFILE_SETTINGS: '/settings/profile',
  1850. SEARCH: '/search',
  1851. TIMELINE_SETTINGS: '/home/pinned/edit',
  1852. }
  1853.  
  1854. /** @enum {string} */
  1855. const ModalPaths = {
  1856. COMPOSE_DRAFTS: '/compose/post/unsent/drafts',
  1857. COMPOSE_MEDIA: '/compose/post/media',
  1858. COMPOSE_MESSAGE: '/messages/compose',
  1859. COMPOSE_SCHEDULE: '/compose/post/schedule',
  1860. COMPOSE_TWEET: '/compose/post',
  1861. GIF_SEARCH: '/i/foundmedia/search',
  1862. }
  1863.  
  1864. /** @enum {string} */
  1865. const Selectors = {
  1866. BLOCK_MENU_ITEM: '[data-testid="block"]',
  1867. DESKTOP_TIMELINE_HEADER: 'div[data-testid="primaryColumn"] > div > div:first-of-type',
  1868. DISPLAY_DONE_BUTTON_DESKTOP: '#layers button[role="button"]:not([aria-label])',
  1869. DISPLAY_DONE_BUTTON_MOBILE: 'main button[role="button"]:not([aria-label])',
  1870. MODAL_TIMELINE: 'section > h1 + div[aria-label] > div',
  1871. MOBILE_TIMELINE_HEADER: 'div[data-testid="TopNavBar"]',
  1872. MORE_DIALOG: 'div[aria-labelledby="modal-header"]',
  1873. NAV_HOME_LINK: 'a[data-testid="AppTabBar_Home_Link"]',
  1874. PRIMARY_COLUMN: 'div[data-testid="primaryColumn"]',
  1875. PRIMARY_NAV_DESKTOP: 'header nav',
  1876. PRIMARY_NAV_MOBILE: '#layers nav',
  1877. PROMOTED_TWEET_CONTAINER: '[data-testid="placementTracking"]',
  1878. SIDEBAR: 'div[data-testid="sidebarColumn"]',
  1879. SIDEBAR_WRAPPERS: 'div[data-testid="sidebarColumn"] > div > div > div > div > div',
  1880. SORT_REPLIES_PATH: 'svg path[d="M14 6V3h2v8h-2V8H3V6h11zm7 2h-3.5V6H21v2zM8 16v-3h2v8H8v-3H3v-2h5zm13 2h-9.5v-2H21v2z"]',
  1881. TIMELINE: 'div[data-testid="primaryColumn"] section > h1 + div[aria-label] > div',
  1882. TIMELINE_HEADING: 'h2[role="heading"]',
  1883. TWEET: '[data-testid="tweet"]',
  1884. VERIFIED_TICK: 'svg[data-testid="icon-verified"]',
  1885. 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"]',
  1886. 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"]',
  1887. }
  1888.  
  1889. /** @enum {string} */
  1890. const Svgs = {
  1891. 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',
  1892. 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>',
  1893. 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>',
  1894. 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>',
  1895. 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',
  1896. 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',
  1897. 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',
  1898. 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',
  1899. 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',
  1900. 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',
  1901. PLUS_PATH: 'M11 11V4h2v7h7v2h-7v7h-2v-7H4v-2h7z',
  1902. }
  1903.  
  1904. /** @enum {string} */
  1905. const Images = {
  1906. 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',
  1907. 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==',
  1908. }
  1909.  
  1910. const THEME_BLUE = 'rgb(29, 155, 240)'
  1911. const THEME_COLORS = new Map([
  1912. ['blue500', THEME_BLUE],
  1913. ['yellow500', 'rgb(255, 212, 0)'],
  1914. ['magenta500', 'rgb(249, 24, 128)'],
  1915. ['purple500', 'rgb(120, 86, 255)'],
  1916. ['orange500', 'rgb(255, 122, 0)'],
  1917. ['green500', 'rgb(0, 186, 124)'],
  1918. ])
  1919. const HIGH_CONTRAST_LIGHT = new Map([
  1920. ['blue500', 'rgb(0, 56, 134)'],
  1921. ['yellow500', 'rgb(111, 62, 0)'],
  1922. ['magenta500', 'rgb(137, 10, 70)'],
  1923. ['purple500', 'rgb(82, 52, 183)'],
  1924. ['orange500', 'rgb(137, 43, 0)'],
  1925. ['green500', 'rgb(0, 97, 61)'],
  1926. ])
  1927. const HIGH_CONTRAST_DARK = new Map([
  1928. ['blue500', 'rgb(107, 201, 251)'],
  1929. ['yellow500', 'rgb(255, 235, 107)'],
  1930. ['magenta500', 'rgb(251, 112, 176)'],
  1931. ['purple500', 'rgb(172, 151, 255)'],
  1932. ['orange500', 'rgb(255, 173, 97)'],
  1933. ['green500', 'rgb(97, 214, 163)'],
  1934. ])
  1935. const COMPOSE_TWEET_MODAL_PAGES = new Set([
  1936. ModalPaths.COMPOSE_DRAFTS,
  1937. ModalPaths.COMPOSE_MEDIA,
  1938. ModalPaths.COMPOSE_SCHEDULE,
  1939. ModalPaths.GIF_SEARCH,
  1940. ])
  1941. // <body> pseudo-selector for pages the full-width content feature works on
  1942. const FULL_WIDTH_BODY_PSEUDO = ':is(.Community, .List, .HomeTimeline)'
  1943. // Matches any notification count at the start of the title
  1944. const TITLE_NOTIFICATION_RE = /^\(\d+\+?\) /
  1945. // The Communities nav item takes you to /yourusername/communities
  1946. const URL_COMMUNITIES_RE = /^\/[a-zA-Z\d_]{1,20}\/communities(?:\/explore)?\/?$/
  1947. const URL_COMMUNITY_RE = /^\/i\/communities\/\d+(?:\/about)?\/?$/
  1948. const URL_COMMUNITY_MEMBERS_RE = /^\/i\/communities\/\d+\/(?:members|moderators)\/?$/
  1949. const URL_DISCOVER_COMMUNITIES_RE = /^\/i\/communities\/suggested\/?/
  1950. const URL_LIST_RE = /\/i\/lists\/\d+\/?$/
  1951. const URL_LISTS_RE = /^\/[a-zA-Z\d_]{1,20}\/lists\/?$/
  1952. const URL_MEDIA_RE = /\/(?:photo|video)\/\d\/?$/
  1953. const URL_MEDIAVIEWER_RE = /^\/[a-zA-Z\d_]{1,20}\/status\/\d+\/mediaviewer$/i
  1954. // Matches URLs which show one of the tabs on a user profile page
  1955. const URL_PROFILE_RE = /^\/([a-zA-Z\d_]{1,20})(?:\/(affiliates|with_replies|superfollows|highlights|articles|media|likes))?\/?$/
  1956. // Matches URLs which show a user's Followers you know / Followers / Following tab
  1957. const URL_PROFILE_FOLLOWS_RE = /^\/[a-zA-Z\d_]{1,20}\/(?:verified_followers|follow(?:ing|ers|ers_you_follow)|creator-subscriptions\/subscriptions)\/?$/
  1958. const URL_TWEET_RE = /^\/([a-zA-Z\d_]{1,20})\/status\/(\d+)\/?$/
  1959. const URL_TWEET_ENGAGEMENT_RE = /^\/[a-zA-Z\d_]{1,20}\/status\/\d+\/(quotes|retweets|reposts|likes)\/?$/
  1960.  
  1961. // The Twitter Media Assist exension adds a new button at the end of the action
  1962. // bar (#346)
  1963. const TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR = '.tva-download-icon, .tva-modal-download-icon'
  1964. //#endregion
  1965.  
  1966. //#region Variables
  1967. /**
  1968. * The quoted Tweet associated with a caret menu that's just been opened.
  1969. * @type {import("./types").QuotedTweet}
  1970. */
  1971. let quotedTweet = null
  1972.  
  1973. /** `true` when a 'Block @${user}' menu item was seen in the last popup. */
  1974. let blockMenuItemSeen = false
  1975.  
  1976. /** `true` if the user has used the "Sort replies by" menu */
  1977. let userSortedReplies = false
  1978.  
  1979. /** Notification count in the title (including trailing space), e.g. `'(1) '`. */
  1980. let currentNotificationCount = ''
  1981.  
  1982. /** The last notification count we hid from the title. */
  1983. let hiddenNotificationCount = ''
  1984.  
  1985. /** Title of the current page, without the `' / Twitter'` suffix. */
  1986. let currentPage = ''
  1987.  
  1988. /** Current `location.pathname`. */
  1989. let currentPath = ''
  1990.  
  1991. /**
  1992. * React Native stylesheet rule for the blur filter for sensitive content.
  1993. * @type {CSSStyleRule}
  1994. */
  1995. let filterBlurRule = null
  1996.  
  1997. /**
  1998. * React Native stylesheett rule for the Chirp font-family.
  1999. * @type {CSSStyleRule}
  2000. */
  2001. let fontFamilyRule = null
  2002.  
  2003. /** @type {string} */
  2004. let fontSize = null
  2005.  
  2006. /** Set to `true` when a Home/Following heading or Home nav link is used. */
  2007. let homeNavigationIsBeingUsed = false
  2008.  
  2009. /** Set to `true` when the media modal is open on desktop. */
  2010. let isDesktopMediaModalOpen = false
  2011.  
  2012. /** Set to `true` when the compose tweet modal is open on desktop. */
  2013. let isDesktopComposeTweetModalOpen = false
  2014.  
  2015. /** @type {HTMLElement} */
  2016. let $desktopComposeTweetModalPopup = null
  2017.  
  2018. /**
  2019. * Cache for the last page title which was used for the Home timeline.
  2020. * @type {string}
  2021. */
  2022. let lastHomeTimelineTitle = null
  2023.  
  2024. /**
  2025. * MutationObservers active on the current modal.
  2026. * @type {import("./types").Disconnectable[]}
  2027. */
  2028. let modalObservers = []
  2029.  
  2030. /**
  2031. * `true` after the app has initialised.
  2032. * @type {boolean}
  2033. */
  2034. let observingPageChanges = false
  2035.  
  2036. /**
  2037. * MutationObservers active on the current page, or anything else we want to
  2038. * clean up when the user moves off the current page.
  2039. * @type {import("./types").NamedMutationObserver[]}
  2040. */
  2041. let pageObservers = []
  2042.  
  2043. /** @type {number} */
  2044. let selectedHomeTabIndex = -1
  2045.  
  2046. /**
  2047. * Title for the fake timeline used to separate out retweets and quote tweets.
  2048. * @type {string}
  2049. */
  2050. let separatedTweetsTimelineTitle = null
  2051.  
  2052. /**
  2053. * The current "Color" setting.
  2054. * @type {string}
  2055. */
  2056. let themeColor = THEME_BLUE
  2057.  
  2058. /**
  2059. * Tab to switch to after navigating to the Tweet interactions page.
  2060. * @type {string}
  2061. */
  2062. let tweetInteractionsTab = null
  2063.  
  2064. /**
  2065. * `true` when "For you" was the last tab selected on the Home timeline.
  2066. */
  2067. let wasForYouTabSelected = false
  2068.  
  2069. function isOnAccessibilitySettingsPage() {
  2070. return currentPath == PagePaths.ACCESSIBILITY_SETTINGS
  2071. }
  2072.  
  2073. function isOnBookmarksPage() {
  2074. return currentPath.startsWith(PagePaths.BOOKMARKS)
  2075. }
  2076.  
  2077. function isOnCommunitiesPage() {
  2078. return URL_COMMUNITIES_RE.test(currentPath)
  2079. }
  2080.  
  2081. function isOnCommunityPage() {
  2082. return URL_COMMUNITY_RE.test(currentPath)
  2083. }
  2084.  
  2085. function isOnCommunityMembersPage() {
  2086. return URL_COMMUNITY_MEMBERS_RE.test(currentPath)
  2087. }
  2088.  
  2089. function isOnDiscoverCommunitiesPage() {
  2090. return URL_DISCOVER_COMMUNITIES_RE.test(currentPath)
  2091. }
  2092.  
  2093. function isOnDisplaySettingsPage() {
  2094. return currentPath == PagePaths.DISPLAY_SETTINGS
  2095. }
  2096.  
  2097. function isOnExplorePage() {
  2098. return currentPath == '/explore' || currentPath.startsWith('/explore/')
  2099. }
  2100.  
  2101. function isOnFollowListPage() {
  2102. return URL_PROFILE_FOLLOWS_RE.test(currentPath)
  2103. }
  2104.  
  2105. function isOnIndividualTweetPage() {
  2106. return URL_TWEET_RE.test(currentPath)
  2107. }
  2108.  
  2109. function isOnListPage() {
  2110. return URL_LIST_RE.test(currentPath)
  2111. }
  2112.  
  2113. function isOnListsPage() {
  2114. return URL_LISTS_RE.test(currentPath)
  2115. }
  2116.  
  2117. function isOnHomeTimelinePage() {
  2118. return currentPath == PagePaths.HOME
  2119. }
  2120.  
  2121. function isOnMessagesPage() {
  2122. return currentPath.startsWith('/messages')
  2123. }
  2124.  
  2125. function isOnNotificationsPage() {
  2126. return currentPath.startsWith('/notifications')
  2127. }
  2128.  
  2129. function isOnProfilePage() {
  2130. let profilePathUsername = currentPath.match(URL_PROFILE_RE)?.[1]
  2131. if (!profilePathUsername) return false
  2132. // twitter.com/user and its sub-URLs put @user in the title
  2133. return currentPage.toLowerCase().includes(`${ltr ? '@' : ''}${profilePathUsername.toLowerCase()}${!ltr ? '@' : ''}`)
  2134. }
  2135.  
  2136. function isOnQuoteTweetsPage() {
  2137. let match = currentPath.match(URL_TWEET_ENGAGEMENT_RE)
  2138. return match?.[1] == 'quotes'
  2139. }
  2140.  
  2141. function isOnSearchPage() {
  2142. return currentPath.startsWith('/search') || currentPath.startsWith('/hashtag/')
  2143. }
  2144.  
  2145. function isOnSeparatedTweetsTimeline() {
  2146. return currentPage == separatedTweetsTimelineTitle
  2147. }
  2148.  
  2149. function isOnSettingsPage() {
  2150. return currentPath.startsWith('/settings')
  2151. }
  2152.  
  2153. function shouldHideSidebar() {
  2154. return isOnExplorePage() || isOnDiscoverCommunitiesPage()
  2155. }
  2156.  
  2157. function shouldShowSeparatedTweetsTab() {
  2158. return config.retweets == 'separate' || config.quoteTweets == 'separate'
  2159. }
  2160. //#endregion
  2161.  
  2162. //#region Utility functions
  2163. /**
  2164. * @param {string} role
  2165. * @returns {HTMLStyleElement}
  2166. */
  2167. function addStyle(role) {
  2168. let $style = document.createElement('style')
  2169. $style.dataset.insertedBy = 'control-panel-for-twitter'
  2170. $style.dataset.role = role
  2171. document.head.appendChild($style)
  2172. return $style
  2173. }
  2174.  
  2175. /**
  2176. * @param {Element} $svg
  2177. */
  2178. function blueCheck($svg) {
  2179. if (!$svg) {
  2180. warn('blueCheck was given', $svg)
  2181. return
  2182. }
  2183. $svg.classList.add('tnt_blue_check')
  2184. // Safari doesn't support using `d: path(…)` to replace paths in an SVG, so
  2185. // we have to manually patch the path in it.
  2186. if (isSafari && config.twitterBlueChecks == 'replace') {
  2187. $svg.firstElementChild.firstElementChild.setAttribute('d', Svgs.BLUE_LOGO_PATH)
  2188. }
  2189. }
  2190.  
  2191. /**
  2192. * @param {Element} $svgPath
  2193. */
  2194. function twitterLogo($svgPath) {
  2195. // Safari doesn't support using `d: path(…)` to replace paths in an SVG, so
  2196. // we have to manually patch the path in it.
  2197. $svgPath.setAttribute('d', Svgs.TWITTER_LOGO_PATH)
  2198. $svgPath.classList.add('tnt_logo')
  2199. }
  2200.  
  2201. /**
  2202. * @param {Element} $svgPath
  2203. */
  2204. function homeIcon($svgPath) {
  2205. // Safari doesn't support using `d: path(…)` to replace paths in an SVG, so
  2206. // we have to manually patch the path in it.
  2207. let replacementPath = {
  2208. [Svgs.X_HOME_ACTIVE_PATH]: Svgs.TWITTER_HOME_ACTIVE_PATH,
  2209. [Svgs.X_HOME_INACTIVE_PATH]: Svgs.TWITTER_HOME_INACTIVE_PATH,
  2210. }[$svgPath.getAttribute('d')]
  2211. if (replacementPath) {
  2212. $svgPath.setAttribute('d', replacementPath)
  2213. }
  2214. }
  2215.  
  2216. /**
  2217. * @param {string} str
  2218. * @returns {string}
  2219. */
  2220. function dedent(str) {
  2221. str = str.replace(/^[ \t]*\r?\n/, '')
  2222. let indent = /^[ \t]+/m.exec(str)
  2223. if (indent) str = str.replace(new RegExp('^' + indent[0], 'gm'), '')
  2224. return str.replace(/(\r?\n)[ \t]+$/, '$1')
  2225. }
  2226.  
  2227. /**
  2228. * @param {string} name
  2229. * @param {import("./types").Disconnectable[]} observers
  2230. */
  2231. function disconnectObserver(name, observers) {
  2232. for (let i = observers.length -1; i >= 0; i--) {
  2233. let observer = observers[i]
  2234. if ('name' in observer && observer.name == name) {
  2235. observer.disconnect()
  2236. observers.splice(i, 1)
  2237. log(`disconnected ${name} ${observers === pageObservers ? 'page' : 'modal'} observer`)
  2238. }
  2239. }
  2240. }
  2241.  
  2242. function disconnectModalObserver(name) {
  2243. disconnectObserver(name, modalObservers)
  2244. }
  2245.  
  2246. function disconnectAllModalObservers() {
  2247. if (modalObservers.length > 0) {
  2248. log(
  2249. `disconnecting ${modalObservers.length} modal observer${s(modalObservers.length)}`,
  2250. modalObservers.map(observer => observer['name'])
  2251. )
  2252. modalObservers.forEach(observer => observer.disconnect())
  2253. modalObservers = []
  2254. }
  2255. }
  2256.  
  2257. function disconnectPageObserver(name) {
  2258. disconnectObserver(name, pageObservers)
  2259. }
  2260.  
  2261. /**
  2262. * @param {MutationRecord[]} mutations
  2263. * @param {($el: Node) => boolean | HTMLElement} fn - return `true` to use [$el]
  2264. * as the result, or return a different HTMLElement to use it as the result.
  2265. * @returns {Node | HTMLElement | null}
  2266. */
  2267. function findAddedNode(mutations, fn) {
  2268. for (let mutation of mutations) {
  2269. for (let el of mutation.addedNodes) {
  2270. let result = fn(el)
  2271. if (result) {
  2272. return result === true ? el : result
  2273. }
  2274. }
  2275. }
  2276. return null
  2277. }
  2278.  
  2279. /**
  2280. * @param {string} selector
  2281. * @param {{
  2282. * name?: string
  2283. * stopIf?: () => boolean
  2284. * timeout?: number
  2285. * context?: Document | HTMLElement
  2286. * }?} options
  2287. * @returns {Promise<HTMLElement | null>}
  2288. */
  2289. function getElement(selector, {
  2290. name = null,
  2291. stopIf = null,
  2292. timeout = Infinity,
  2293. context = document,
  2294. } = {}) {
  2295. return new Promise((resolve) => {
  2296. let startTime = Date.now()
  2297. let rafId
  2298. let timeoutId
  2299.  
  2300. function stop($element, reason) {
  2301. if ($element == null) {
  2302. warn(`stopped waiting for ${name || selector} after ${reason}`)
  2303. }
  2304. else if (Date.now() > startTime) {
  2305. log(`${name || selector} appeared after ${Date.now() - startTime}ms`)
  2306. }
  2307. if (rafId) {
  2308. cancelAnimationFrame(rafId)
  2309. }
  2310. if (timeoutId) {
  2311. clearTimeout(timeoutId)
  2312. }
  2313. resolve($element)
  2314. }
  2315.  
  2316. if (timeout !== Infinity) {
  2317. timeoutId = setTimeout(stop, timeout, null, `${timeout}ms timeout`)
  2318. }
  2319.  
  2320. function queryElement() {
  2321. let $element = context.querySelector(selector)
  2322. if ($element) {
  2323. stop($element)
  2324. }
  2325. else if (stopIf?.() === true) {
  2326. stop(null, 'stopIf condition met')
  2327. }
  2328. else {
  2329. rafId = requestAnimationFrame(queryElement)
  2330. }
  2331. }
  2332.  
  2333. queryElement()
  2334. })
  2335. }
  2336.  
  2337. function getState() {
  2338. let wrapped = $reactRoot.firstElementChild['wrappedJSObject'] || $reactRoot.firstElementChild
  2339. let reactPropsKey = Object.keys(wrapped).find(key => key.startsWith('__reactProps'))
  2340. if (reactPropsKey) {
  2341. let state = wrapped[reactPropsKey].children?.props?.children?.props?.store?.getState()
  2342. if (state) return state
  2343. warn('React state not found')
  2344. } else {
  2345. warn('React prop key not found')
  2346. }
  2347. }
  2348.  
  2349. function hasNewLayout() {
  2350. return getState()?.featureSwitch?.user?.config?.rweb_sourcemap_migration?.value
  2351. }
  2352.  
  2353. function getNotificationCount() {
  2354. let state = getState()
  2355. if (!state || !state.badgeCount) {
  2356. warn('could not get notification count from state')
  2357. return 0
  2358. }
  2359. return state.badgeCount.unreadDMCount + state.badgeCount.unreadNTabCount;
  2360. }
  2361.  
  2362. function getStateEntities() {
  2363. let state = getState()
  2364. if (state) {
  2365. if (state.entities) return state.entities
  2366. warn('React state entities not found')
  2367. }
  2368. }
  2369.  
  2370. function getUserScreenName() {
  2371. let state = getState()
  2372. return state?.entities?.users?.entities?.[state?.session?.user_id]?.screen_name
  2373. }
  2374.  
  2375. function getThemeColorFromState() {
  2376. let localState = getState().settings?.local
  2377. let color = localState?.themeColor
  2378. let highContrast = localState?.highContrastEnabled
  2379. $body.classList.toggle('HighContrast', highContrast)
  2380. if (color) {
  2381. if (THEME_COLORS.has(color)) {
  2382. let colors = THEME_COLORS
  2383. if (highContrast) colors = getColorScheme() == 'Default' ? HIGH_CONTRAST_LIGHT : HIGH_CONTRAST_DARK
  2384. return colors.get(color)
  2385. }
  2386. warn(color, 'not found in THEME_COLORS')
  2387. } else {
  2388. warn('could not get settings.local.themeColor from React state')
  2389. }
  2390. }
  2391.  
  2392. /**
  2393. * Gets cached tweet info from React state.
  2394. */
  2395. function getTweetInfo(id) {
  2396. let tweetEntities = getStateEntities()?.tweets?.entities
  2397. if (tweetEntities) {
  2398. let tweetInfo = tweetEntities[id]
  2399. if (!tweetInfo) {
  2400. warn('tweet info not found')
  2401. }
  2402. return tweetInfo
  2403. } else {
  2404. warn('tweet entities not found')
  2405. }
  2406. }
  2407.  
  2408. /**
  2409. * Gets cached user info from React state.
  2410. * @returns {import("./types").UserInfoObject}
  2411. */
  2412. function getUserInfo() {
  2413. /** @type {import("./types").UserInfoObject} */
  2414. let userInfo = {}
  2415. let userEntities = getStateEntities()?.users?.entities
  2416. if (userEntities) {
  2417. for (let user of Object.values(userEntities)) {
  2418. userInfo[user.screen_name] = {
  2419. following: user.following,
  2420. followedBy: user.followed_by,
  2421. followersCount: user.followers_count,
  2422. }
  2423. }
  2424. } else {
  2425. warn('user entities not found')
  2426. }
  2427. return userInfo
  2428. }
  2429.  
  2430. /**
  2431. * @param {import("./types").Disconnectable[]} observers
  2432. * @param {string} name
  2433. */
  2434. function isObserving(observers, name) {
  2435. return observers.some(observer => 'name' in observer && observer.name == name)
  2436. }
  2437.  
  2438. function log(...args) {
  2439. if (debug) {
  2440. let page = currentPage?.replace(/(\r?\n)+/g, ' ')
  2441. console.log(`${page ? `(${
  2442. page.length < 42 ? page : page.slice(0, 42) + '…'
  2443. })` : ''}`, ...args)
  2444. }
  2445. }
  2446.  
  2447. function warn(...args) {
  2448. if (debug) {
  2449. console.log(`❗ ${currentPage ? `(${currentPage})` : ''}`, ...args)
  2450. }
  2451. }
  2452.  
  2453. function error(...args) {
  2454. console.log(`❌ ${currentPage ? `(${currentPage})` : ''}`, ...args)
  2455. }
  2456.  
  2457. /**
  2458. * @param {() => boolean} condition
  2459. * @returns {() => boolean}
  2460. */
  2461. function not(condition) {
  2462. return () => !condition()
  2463. }
  2464.  
  2465. /**
  2466. * Convenience wrapper for the MutationObserver API - the callback is called
  2467. * immediately to support using an observer and its options as a trigger for any
  2468. * change, without looking at MutationRecords.
  2469. * @param {Node} $element
  2470. * @param {MutationCallback} callback
  2471. * @param {string} name
  2472. * @param {MutationObserverInit} options
  2473. * @returns {import("./types").NamedMutationObserver}
  2474. */
  2475. function observeElement($element, callback, name, options = {childList: true}) {
  2476. if (name) {
  2477. if (options.childList && callback.length > 0) {
  2478. log(`observing ${name}`, $element)
  2479. } else {
  2480. log (`observing ${name}`)
  2481. }
  2482. }
  2483.  
  2484. let observer = new MutationObserver(callback)
  2485. callback([], observer)
  2486. observer.observe($element, options)
  2487. observer['name'] = name
  2488. return observer
  2489. }
  2490.  
  2491. /**
  2492. * @param {string} page
  2493. * @returns {() => boolean}
  2494. */
  2495. function pageIsNot(page) {
  2496. return function() {
  2497. let pageChanged = page != currentPage
  2498. if (pageChanged) {
  2499. log('pageIsNot', {page, currentPage})
  2500. }
  2501. return pageChanged
  2502. }
  2503. }
  2504.  
  2505. /**
  2506. * @param {string} path
  2507. * @returns {() => boolean}
  2508. */
  2509. function pathIsNot(path) {
  2510. return () => path != currentPath
  2511. }
  2512.  
  2513. /**
  2514. * @param {number} n
  2515. * @returns {string}
  2516. */
  2517. function s(n) {
  2518. return n == 1 ? '' : 's'
  2519. }
  2520.  
  2521. /**
  2522. * @param {Element} $tweetButtonText
  2523. */
  2524. function setTweetButtonText($tweetButtonText) {
  2525. let currentText = $tweetButtonText.textContent
  2526. if (currentText == getString('TWEET') || currentText == getString('TWEET_ALL')) return
  2527. $tweetButtonText.textContent = currentText == getString('POST_ALL') ? getString('TWEET_ALL') : getString('TWEET')
  2528. }
  2529.  
  2530. function storeConfigChanges(changes) {
  2531. window.postMessage({type: 'tntConfigChange', changes})
  2532. }
  2533. //#endregion
  2534.  
  2535. //#region Global observers
  2536. const checkReactNativeStylesheet = (() => {
  2537. /** @type {number} */
  2538. let startTime
  2539.  
  2540. return function checkReactNativeStylesheet() {
  2541. startTime ??= Date.now()
  2542.  
  2543. let $style = /** @type {HTMLStyleElement} */ (document.querySelector('style#react-native-stylesheet'))
  2544. if (!$style) {
  2545. warn('React Native stylesheet not found')
  2546. return
  2547. }
  2548.  
  2549. for (let rule of $style.sheet.cssRules) {
  2550. if (!(rule instanceof CSSStyleRule)) continue
  2551.  
  2552. if (fontFamilyRule == null &&
  2553. rule.style.fontFamily?.includes('TwitterChirp') &&
  2554. !rule.style.fontFamily.includes('TwitterChirpExtendedHeavy')) {
  2555. fontFamilyRule = rule
  2556. log('found Chirp fontFamily CSS rule in React Native stylesheet')
  2557. configureFont()
  2558. }
  2559.  
  2560. if (filterBlurRule == null && rule.style.filter?.includes('blur(30px)')) {
  2561. filterBlurRule = rule
  2562. log('found filter: blur(30px) rule in React Native stylesheet', filterBlurRule)
  2563. configureDynamicCss()
  2564. }
  2565. }
  2566.  
  2567. let elapsedTime = Date.now() - startTime
  2568. if (fontFamilyRule == null || filterBlurRule == null) {
  2569. if (elapsedTime < 3000) {
  2570. setTimeout(checkReactNativeStylesheet, 100)
  2571. } else {
  2572. warn(`stopped checking React Native stylesheet after ${elapsedTime}ms`)
  2573. }
  2574. } else {
  2575. log(`finished checking React Native stylesheet in ${elapsedTime}ms`)
  2576. }
  2577. }
  2578. })()
  2579.  
  2580. /**
  2581. * When the "Background" setting is changed, <body>'s backgroundColor is changed
  2582. * and the app is re-rendered, so we need to re-process the current page.
  2583. */
  2584. function observeBodyBackgroundColor() {
  2585. let lastBackgroundColor = null
  2586.  
  2587. observeElement($body, () => {
  2588. let backgroundColor = $body.style.backgroundColor
  2589. if (backgroundColor == lastBackgroundColor) return
  2590.  
  2591. $body.classList.toggle('Default', backgroundColor == 'rgb(255, 255, 255)')
  2592. $body.classList.toggle('Dim', backgroundColor == 'rgb(21, 32, 43)')
  2593. $body.classList.toggle('LightsOut', backgroundColor == 'rgb(0, 0, 0)')
  2594.  
  2595. if (lastBackgroundColor != null) {
  2596. log('Background setting changed - re-processing current page')
  2597. observePopups()
  2598. observeSideNavTweetButton()
  2599. processCurrentPage()
  2600. }
  2601. lastBackgroundColor = backgroundColor
  2602. }, '<body> style attribute for background colour changes', {
  2603. attributes: true,
  2604. attributeFilter: ['style']
  2605. })
  2606. }
  2607.  
  2608. /**
  2609. * @param {HTMLElement} $popup
  2610. */
  2611. async function observeDesktopComposeTweetModal($popup) {
  2612. if (!config.replaceLogo) return
  2613.  
  2614. let $mask = await getElement('[data-testid="twc-cc-mask"]', {
  2615. context: $popup,
  2616. name: 'Compose Tweet modal mask',
  2617. stopIf: () => !isDesktopComposeTweetModalOpen
  2618. })
  2619. if (!$mask) return
  2620.  
  2621. let $tweetButtonText = $popup.querySelector('button[data-testid="tweetButton"] span > span')
  2622. if ($tweetButtonText) {
  2623. setTweetButtonText($tweetButtonText)
  2624. }
  2625.  
  2626. modalObservers.push(
  2627. observeElement($mask.nextElementSibling, () => {
  2628. disconnectModalObserver('Modal Tweet editor root (for placeholder)')
  2629. let $editorRoots = $popup.querySelectorAll('.DraftEditor-root')
  2630. $editorRoots.forEach((/** @type {HTMLElement} */ $editorRoot, index) => {
  2631. $editorRoot.setAttribute('data-placeholder', getString(index == 0 ? 'WHATS_HAPPENING' : 'ADD_ANOTHER_TWEET'))
  2632. observeDesktopTweetEditorPlaceholder($editorRoot, {
  2633. name: 'Modal Tweet editor root (for placeholder)',
  2634. observers: modalObservers,
  2635. })
  2636. })
  2637. }, 'Compose Tweet modal Tweets container (for Tweets being added or removed)')
  2638. )
  2639.  
  2640. // The Tweet button gets moved around when Tweets are added or removed
  2641. modalObservers.push(
  2642. observeElement($mask.nextElementSibling, (mutations) => {
  2643. for (let mutation of mutations) {
  2644. for (let $addedNode of mutation.addedNodes) {
  2645. if (!($addedNode instanceof HTMLElement) || $addedNode.nodeName != 'DIV') continue
  2646. let $tweetButtonText = $addedNode.querySelector('button[data-testid="tweetButton"] span > span')
  2647. if ($tweetButtonText) {
  2648. setTweetButtonText($tweetButtonText)
  2649. }
  2650. }
  2651. }
  2652. }, 'Compose Tweet modal contents (for Tweet button moving)', {
  2653. childList: true,
  2654. subtree: true,
  2655. })
  2656. )
  2657. }
  2658.  
  2659. /**
  2660. * The timeline Tweet box is removed when you navigate to a pinned Communities
  2661. * tab and re-added when you navigate to another Home timeline tab.
  2662. */
  2663. async function observeDesktopHomeTimelineTweetBox() {
  2664. let $container = await getElement('div[data-testid="primaryColumn"] > div', {
  2665. name: 'Home timeline Tweet box container',
  2666. stopIf: pageIsNot(currentPage),
  2667. })
  2668. if (!$container) return
  2669.  
  2670. /**
  2671. * @param {HTMLElement} $tweetBox
  2672. */
  2673. async function observeTweetBox($tweetBox) {
  2674. $tweetBox.classList.add('TweetBox')
  2675.  
  2676. if (config.replaceLogo) {
  2677. // Restore "What's happening?" placeholder
  2678. let $editorRoot = await getElement('.DraftEditor-root', {
  2679. context: $tweetBox,
  2680. name: 'Tweet box editor root',
  2681. stopIf: pageIsNot(currentPage),
  2682. })
  2683. if (!$editorRoot) return
  2684. observeDesktopTweetEditorPlaceholder($editorRoot, {
  2685. observers: pageObservers,
  2686. placeholder: getString('WHATS_HAPPENING'),
  2687. })
  2688. tweakTweetButton()
  2689. }
  2690. }
  2691.  
  2692. /** @type {HTMLElement} */
  2693. let $timelineTweetBox = $container.querySelector(':scope > div:has([data-testid^="tweetTextarea"]')
  2694. if ($timelineTweetBox) {
  2695. log('Home timeline Tweet box present')
  2696. observeTweetBox($timelineTweetBox)
  2697. }
  2698.  
  2699. pageObservers.push(
  2700. observeElement($container, (mutations) => {
  2701. for (let mutation of mutations) {
  2702. for (let $addedNode of mutation.addedNodes) {
  2703. if (!($addedNode instanceof HTMLElement)) continue
  2704. if ($addedNode.querySelector('[data-testid^="tweetTextarea"]')) {
  2705. log('Home timeline Tweet box appeared')
  2706. $timelineTweetBox = $addedNode
  2707. observeTweetBox($timelineTweetBox)
  2708. }
  2709. }
  2710. for (let $removedNode of mutation.removedNodes) {
  2711. if (!($removedNode instanceof HTMLElement)) continue
  2712. if ($removedNode === $timelineTweetBox) {
  2713. log('Home timeline Tweet box removed')
  2714. $timelineTweetBox = null
  2715. disconnectPageObserver('Tweet box editor root')
  2716. }
  2717. }
  2718. }
  2719. }, 'Home timeline Tweet box container')
  2720. )
  2721. }
  2722.  
  2723. /**
  2724. * @param {HTMLElement} $popup
  2725. */
  2726. async function observeDesktopModalTimeline($popup) {
  2727. // Media modals remember if they were previously collapsed, so we could be
  2728. // waiting for the initial timeline to be either rendered or expanded.
  2729. let $initialTimeline = await getElement(Selectors.MODAL_TIMELINE, {
  2730. context: $popup,
  2731. name: 'initial modal timeline',
  2732. stopIf: () => !isDesktopMediaModalOpen,
  2733. })
  2734.  
  2735. if ($initialTimeline == null) return
  2736.  
  2737. /**
  2738. * @param {HTMLElement} $timeline
  2739. */
  2740. function observeModalTimelineItems($timeline) {
  2741. disconnectModalObserver('modal timeline')
  2742. modalObservers.push(
  2743. observeElement($timeline, () => onIndividualTweetTimelineChange($timeline, {observers: modalObservers}), 'modal timeline')
  2744. )
  2745.  
  2746. // If other media in the modal is clicked, the timeline is replaced.
  2747. disconnectModalObserver('modal timeline parent')
  2748. modalObservers.push(
  2749. observeElement($timeline.parentElement, (mutations) => {
  2750. mutations.forEach((mutation) => {
  2751. mutation.addedNodes.forEach((/** @type {HTMLElement} */ $newTimeline) => {
  2752. log('modal timeline replaced')
  2753. disconnectModalObserver('modal timeline')
  2754. modalObservers.push(
  2755. observeElement($newTimeline, () => onIndividualTweetTimelineChange($newTimeline, {observers: modalObservers}), 'modal timeline')
  2756. )
  2757. })
  2758. })
  2759. }, 'modal timeline parent')
  2760. )
  2761. }
  2762.  
  2763. /**
  2764. * @param {HTMLElement} $timeline
  2765. */
  2766. function observeModalTimeline($timeline) {
  2767. // If the inital timeline doesn't have a style attribute it's a placeholder
  2768. if ($timeline.hasAttribute('style')) {
  2769. observeModalTimelineItems($timeline)
  2770. }
  2771. else {
  2772. log('waiting for modal timeline')
  2773. let startTime = Date.now()
  2774. modalObservers.push(
  2775. observeElement($timeline.parentElement, (mutations) => {
  2776. mutations.forEach((mutation) => {
  2777. mutation.addedNodes.forEach((/** @type {HTMLElement} */ $timeline) => {
  2778. disconnectModalObserver('modal timeline parent')
  2779. if (Date.now() > startTime) {
  2780. log(`modal timeline appeared after ${Date.now() - startTime}ms`, $timeline)
  2781. }
  2782. observeModalTimelineItems($timeline)
  2783. })
  2784. })
  2785. }, 'modal timeline parent')
  2786. )
  2787. }
  2788. }
  2789.  
  2790. // The modal timeline can be expanded and collapsed
  2791. let $expandedContainer = $initialTimeline.closest('[aria-expanded="true"]')
  2792. modalObservers.push(
  2793. observeElement($expandedContainer.parentElement, async (mutations) => {
  2794. if (mutations.some(mutation => mutation.removedNodes.length > 0)) {
  2795. log('modal timeline collapsed')
  2796. disconnectModalObserver('modal timeline parent')
  2797. disconnectModalObserver('modal timeline')
  2798. }
  2799. else if (mutations.some(mutation => mutation.addedNodes.length > 0)) {
  2800. log('modal timeline expanded')
  2801. let $timeline = await getElement(Selectors.MODAL_TIMELINE, {
  2802. context: $popup,
  2803. name: 'expanded modal timeline',
  2804. stopIf: () => !isDesktopMediaModalOpen,
  2805. })
  2806. if ($timeline == null) return
  2807. observeModalTimeline($timeline)
  2808. }
  2809. }, 'collapsible modal timeline container')
  2810. )
  2811.  
  2812. observeModalTimeline($initialTimeline)
  2813. }
  2814.  
  2815. const observeFavicon = (() => {
  2816. /** @type {HTMLLinkElement} */
  2817. let $shortcutIcon
  2818.  
  2819. async function observeFavicon() {
  2820. $shortcutIcon = /** @type {HTMLLinkElement} */ (await getElement('link[rel~="icon"]', {
  2821. name: 'shortcut icon'
  2822. }))
  2823.  
  2824. observeElement($shortcutIcon, () => {
  2825. let href = $shortcutIcon.href
  2826. if (config.replaceLogo) {
  2827. // Once we replace the favicon, Twitter stops updating it when
  2828. // notification status changes, so this only handles initial switchover
  2829. // to the Twitter version of the icon.
  2830. if (href.startsWith('data:')) return
  2831. let icon = config.hideNotifications != 'ignore' && href.includes('-pip') ? (
  2832. Images.TWITTER_PIP_FAVICON
  2833. ) : (
  2834. Images.TWITTER_FAVICON
  2835. )
  2836. $shortcutIcon.href = icon
  2837. } else {
  2838. // If we're hiding notifications, detect when Twitter tries to use the
  2839. // pip version and switch back.
  2840. if (config.hideNotifications != 'ignore' && href.includes('-pip')) {
  2841. $shortcutIcon.href = href.replace('-pip', '')
  2842. }
  2843. }
  2844. }, 'shortcut icon href', {
  2845. attributes: true,
  2846. attributeFilter: ['href']
  2847. })
  2848. }
  2849.  
  2850. observeFavicon.forceUpdate = function(showPip) {
  2851. let href = $shortcutIcon.href
  2852. if (config.replaceLogo) {
  2853. href = config.hideNotifications == 'ignore' && showPip ? (
  2854. Images.TWITTER_PIP_FAVICON
  2855. ) : (
  2856. Images.TWITTER_FAVICON
  2857. )
  2858. } else {
  2859. href = `//abs.twimg.com/favicons/twitter${
  2860. config.hideNotifications == 'ignore' && showPip ? '-pip' : ''
  2861. }.3.ico`
  2862. }
  2863. if (href != $shortcutIcon.href) {
  2864. $shortcutIcon.href = href
  2865. }
  2866. }
  2867.  
  2868. return observeFavicon
  2869. })()
  2870.  
  2871. /**
  2872. * Twitter displays popups in the #layers element. It also reuses open popups
  2873. * in certain cases rather than creating one from scratch, so we also need to
  2874. * deal with nested popups, e.g. if you hover over the caret menu in a Tweet, a
  2875. * popup will be created to display a "More" tootip and clicking to open the
  2876. * menu will create a nested element in the existing popup, whereas clicking the
  2877. * caret quickly without hovering over it will display the menu in new popup.
  2878. * Use of nested popups can also differ between desktop and mobile, so features
  2879. * need to be mindful of that.
  2880. */
  2881. const observePopups = (() => {
  2882. /** @type {MutationObserver} */
  2883. let popupObserver
  2884. /** @type {WeakMap<HTMLElement, {disconnect()}>} */
  2885. let nestedObservers = new WeakMap()
  2886.  
  2887. return async function observePopups() {
  2888. if (popupObserver) {
  2889. popupObserver.disconnect()
  2890. popupObserver = null
  2891. }
  2892.  
  2893. let $layers = await getElement('#layers', {
  2894. name: 'layers',
  2895. })
  2896.  
  2897. // There can be only one
  2898. if (popupObserver) {
  2899. popupObserver.disconnect()
  2900. }
  2901.  
  2902. popupObserver = observeElement($layers, (mutations) => {
  2903. mutations.forEach((mutation) => {
  2904. mutation.addedNodes.forEach((/** @type {HTMLElement} */ $el) => {
  2905. let nestedObserver = onPopup($el)
  2906. if (nestedObserver) {
  2907. nestedObservers.set($el, nestedObserver)
  2908. }
  2909. })
  2910. mutation.removedNodes.forEach((/** @type {HTMLElement} */ $el) => {
  2911. if (nestedObservers.has($el)) {
  2912. nestedObservers.get($el).disconnect()
  2913. nestedObservers.delete($el)
  2914. }
  2915. })
  2916. })
  2917. }, 'popup container')
  2918. }
  2919. })()
  2920.  
  2921. async function observeTitle() {
  2922. let $title = await getElement('title', {name: '<title>'})
  2923. observeElement($title, () => {
  2924. let title = $title.textContent
  2925. if (title.match(/^Intervention for (X|Twitter)$/)) {
  2926. log('Ignoring one sec extension title')
  2927. return
  2928. }
  2929. if (config.replaceLogo && (ltr ? /X$/ : /^(?:\(\d+\+?\) )?X/).test(title)) {
  2930. title = title.replace(ltr ? /X$/ : 'X', getString('TWITTER'))
  2931. }
  2932. if (config.hideNotifications != 'ignore' && TITLE_NOTIFICATION_RE.test(title)) {
  2933. hiddenNotificationCount = TITLE_NOTIFICATION_RE.exec(title)[0]
  2934. title = title.replace(TITLE_NOTIFICATION_RE, '')
  2935. }
  2936. if (title != $title.textContent) {
  2937. document.title = title
  2938. // If Twitter is opened in the background, changing the title might not
  2939. // re-fire the title MutationObserver, preventing the initial page from
  2940. // being processed.
  2941. if (!currentPage) {
  2942. onTitleChange(title)
  2943. }
  2944. return
  2945. }
  2946. if (observingPageChanges) {
  2947. onTitleChange(title)
  2948. }
  2949. }, '<title>')
  2950. }
  2951. //#endregion
  2952.  
  2953. //#region Page observers
  2954. async function observeSidebar() {
  2955. let $primaryColumn = await getElement(Selectors.PRIMARY_COLUMN, {
  2956. name: 'primary column'
  2957. })
  2958. let $sidebarContainer = $primaryColumn.parentElement
  2959. pageObservers.push(
  2960. observeElement($sidebarContainer, () => {
  2961. let $sidebar = /** @type {HTMLElement} */ ($sidebarContainer.querySelector(Selectors.SIDEBAR))
  2962. log(`sidebar ${$sidebar ? 'appeared' : 'disappeared'}`)
  2963. $body.classList.toggle('Sidebar', Boolean($sidebar))
  2964. if (!$sidebar) {
  2965. if (!config.hideSidebarContent && !isOnExplorePage()) disconnectPageObserver("sidebar What's happening timeline")
  2966. return
  2967. }
  2968. // Process blue checks in the sidebar search dropdown
  2969. if (config.twitterBlueChecks != 'ignore' && !isOnSearchPage() && !isOnExplorePage()) {
  2970. observeSearchForm()
  2971. }
  2972. // Process blue checks in the sidebar user box
  2973. if (config.twitterBlueChecks != 'ignore' && (!config.hideSidebarContent || config.showRelevantPeople && isOnIndividualTweetPage())) {
  2974. void async function() {
  2975. let $aside = await getElement('aside[role="complementary"]', {
  2976. name: 'sidebar aside box',
  2977. context: $sidebar,
  2978. stopIf: pageIsNot(currentPage),
  2979. timeout: 2000,
  2980. })
  2981. if ($aside) processBlueChecks($aside)
  2982. }()
  2983. }
  2984. // Hide the ad in sidebar What's happening
  2985. if (!config.hideSidebarContent && !isOnExplorePage()) {
  2986. void async function() {
  2987. let $whatsHappeningTimeline = await getElement('section > div[aria-label] > div', {
  2988. name: "hideSidebarWhatsHappeningAd: sidebar What's happening timeline",
  2989. context: $sidebar,
  2990. stopIf: pageIsNot(currentPage),
  2991. timeout: 2000,
  2992. })
  2993. if (!$whatsHappeningTimeline) return
  2994. // The sidebar What's happening timeline loads asynchronously and
  2995. // refreshes every time the page regains refocus.
  2996. pageObservers.push(
  2997. observeElement($whatsHappeningTimeline, () => {
  2998. let $firstTrend = $whatsHappeningTimeline.querySelector(':scope > div:has([data-testid="trend"])')
  2999. if ($firstTrend && !$firstTrend.previousElementSibling.classList.contains('HiddenAd')) {
  3000. $firstTrend.previousElementSibling.classList.toggle('HiddenAd', !$firstTrend.previousElementSibling.querySelector('h2'))
  3001. }
  3002. }, "sidebar What's happening timeline", {childList: true, subtree: true})
  3003. )
  3004. }()
  3005. }
  3006. }, 'sidebar container')
  3007. )
  3008. }
  3009.  
  3010. const observeSideNavTweetButton = (() => {
  3011. /** @type {MutationObserver} */
  3012. let observer
  3013.  
  3014. return async function observeSideNavTweetButton() {
  3015. if (observer) {
  3016. observer.disconnect()
  3017. observer = null
  3018. }
  3019.  
  3020. if (!desktop || !config.replaceLogo) return
  3021.  
  3022. // This element is updated when text is added or removed on resize
  3023. let $buttonTextContainer = await getElement('a[data-testid="SideNav_NewTweet_Button"] > div > span', {
  3024. name: 'sidenav tweet button text container',
  3025. })
  3026. observer = observeElement($buttonTextContainer, () => {
  3027. if ($buttonTextContainer.childElementCount > 0) {
  3028. let $buttonText = /** @type {HTMLElement} */ ($buttonTextContainer.querySelector('span > span'))
  3029. if ($buttonText) {
  3030. setTweetButtonText($buttonText)
  3031. } else {
  3032. warn('could not find tweet button text')
  3033. }
  3034. }
  3035. }, 'sidenav tweet button')
  3036. }
  3037. })()
  3038.  
  3039. async function observeSearchForm() {
  3040. let $searchForm = await getElement('form[role="search"]', {
  3041. name: 'search form',
  3042. stopIf: pageIsNot(currentPage),
  3043. // The sidebar on Profile pages can be really slow
  3044. timeout: 2000,
  3045. })
  3046. if (!$searchForm) return
  3047. let $results = /** @type {HTMLElement} */ ($searchForm.lastElementChild)
  3048. pageObservers.push(
  3049. observeElement($results, () => {
  3050. processBlueChecks($results)
  3051. }, 'search results', {childList: true, subtree: true})
  3052. )
  3053. }
  3054.  
  3055. /**
  3056. * @param {string} page
  3057. * @param {import("./types").TimelineOptions?} options
  3058. */
  3059. async function observeTimeline(page, options = {}) {
  3060. let {
  3061. isTabbed = false,
  3062. onTabChanged = null,
  3063. onTimelineAppeared = null,
  3064. tabbedTimelineContainerSelector = null,
  3065. timelineSelector = Selectors.TIMELINE,
  3066. } = options
  3067.  
  3068. let $timeline = await getElement(timelineSelector, {
  3069. name: 'initial timeline',
  3070. stopIf: pageIsNot(page),
  3071. })
  3072.  
  3073. if ($timeline == null) return
  3074.  
  3075. /**
  3076. * @param {HTMLElement} $timeline
  3077. */
  3078. function observeTimelineItems($timeline) {
  3079. disconnectPageObserver('timeline')
  3080. pageObservers.push(
  3081. observeElement($timeline, () => onTimelineChange($timeline, page, options), 'timeline')
  3082. )
  3083. onTimelineAppeared?.()
  3084. if (isTabbed) {
  3085. // When a tab which has been viewed before is revisited, the timeline is
  3086. // replaced.
  3087. disconnectPageObserver('timeline parent')
  3088. pageObservers.push(
  3089. observeElement($timeline.parentElement, (mutations) => {
  3090. mutations.forEach((mutation) => {
  3091. mutation.addedNodes.forEach((/** @type {HTMLElement} */ $newTimeline) => {
  3092. disconnectPageObserver('timeline')
  3093. log('tab changed')
  3094. onTabChanged?.()
  3095. pageObservers.push(
  3096. observeElement($newTimeline, () => onTimelineChange($newTimeline, page, options), 'timeline')
  3097. )
  3098. })
  3099. })
  3100. }, 'timeline parent')
  3101. )
  3102. }
  3103. }
  3104.  
  3105. // If the inital timeline doesn't have a style attribute it's a placeholder
  3106. if ($timeline.hasAttribute('style')) {
  3107. observeTimelineItems($timeline)
  3108. }
  3109. else {
  3110. log('waiting for timeline')
  3111. let startTime = Date.now()
  3112. pageObservers.push(
  3113. observeElement($timeline.parentElement, (mutations) => {
  3114. mutations.forEach((mutation) => {
  3115. mutation.addedNodes.forEach((/** @type {HTMLElement} */ $timeline) => {
  3116. disconnectPageObserver('timeline parent')
  3117. if (Date.now() > startTime) {
  3118. log(`timeline appeared after ${Date.now() - startTime}ms`, $timeline)
  3119. }
  3120. observeTimelineItems($timeline)
  3121. })
  3122. })
  3123. }, 'timeline parent')
  3124. )
  3125. }
  3126.  
  3127. // On some tabbed timeline pages, the first time a new tab is navigated to,
  3128. // the element containing the timeline is replaced with a loading spinner.
  3129. if (isTabbed && tabbedTimelineContainerSelector) {
  3130. let $tabbedTimelineContainer = document.querySelector(tabbedTimelineContainerSelector)
  3131. if ($tabbedTimelineContainer) {
  3132. let waitingForNewTimeline = false
  3133. pageObservers.push(
  3134. observeElement($tabbedTimelineContainer, async (mutations) => {
  3135. // This is going to fire twice on a new tab, as the spinner is added
  3136. // then replaced with the new timeline element.
  3137. if (!mutations.some(mutation => mutation.addedNodes.length > 0) || waitingForNewTimeline) return
  3138.  
  3139. waitingForNewTimeline = true
  3140. let $newTimeline = await getElement(timelineSelector, {
  3141. name: 'new timeline',
  3142. stopIf: pageIsNot(page),
  3143. })
  3144. waitingForNewTimeline = false
  3145. if (!$newTimeline) return
  3146.  
  3147. log('tab changed')
  3148. onTabChanged?.()
  3149. observeTimelineItems($newTimeline)
  3150. }, 'tabbed timeline container')
  3151. )
  3152. } else {
  3153. warn('tabbed timeline container not found', tabbedTimelineContainerSelector)
  3154. }
  3155. }
  3156. }
  3157.  
  3158. /**
  3159. * @param {HTMLElement} $editorRoot
  3160. * @param {{
  3161. * name?: string
  3162. * observers: import("./types").Disconnectable[]
  3163. * placeholder?: string
  3164. * }} options
  3165. */
  3166. function observeDesktopTweetEditorPlaceholder($editorRoot, {
  3167. name = 'Tweet editor root (for placeholder)',
  3168. observers,
  3169. placeholder = '',
  3170. }) {
  3171. observers.push(
  3172. observeElement($editorRoot, () => {
  3173. if ($editorRoot.firstElementChild.classList.contains('public-DraftEditorPlaceholder-root')) {
  3174. let $placeholder = $editorRoot.querySelector('.public-DraftEditorPlaceholder-inner')
  3175. placeholder = $editorRoot.getAttribute('data-placeholder') || placeholder
  3176. if ($placeholder && $placeholder.textContent != placeholder) {
  3177. $placeholder.textContent = placeholder
  3178. }
  3179. }
  3180. }, name)
  3181. )
  3182. }
  3183.  
  3184. /**
  3185. * @param {string} page
  3186. */
  3187. async function observeIndividualTweetTimeline(page) {
  3188. let $timeline = await getElement(Selectors.TIMELINE, {
  3189. name: 'initial individual tweet timeline',
  3190. stopIf: pageIsNot(page),
  3191. })
  3192.  
  3193. if ($timeline == null) return
  3194.  
  3195. /**
  3196. * @param {HTMLElement} $timeline
  3197. */
  3198. function observeTimelineItems($timeline) {
  3199. pageObservers.push(
  3200. observeElement($timeline, () => onIndividualTweetTimelineChange($timeline, {observers: pageObservers}), 'individual tweet timeline')
  3201. )
  3202. }
  3203.  
  3204. // If the inital timeline doesn't have a style attribute it's a placeholder
  3205. if ($timeline.hasAttribute('style')) {
  3206. observeTimelineItems($timeline)
  3207. }
  3208. else {
  3209. log('waiting for individual tweet timeline')
  3210. let startTime = Date.now()
  3211. pageObservers.push(
  3212. observeElement($timeline.parentElement, (mutations) => {
  3213. mutations.forEach((mutation) => {
  3214. mutation.addedNodes.forEach((/** @type {HTMLElement} */ $timeline) => {
  3215. disconnectPageObserver('individual tweet timeline parent')
  3216. if (Date.now() > startTime) {
  3217. log(`individual tweet timeline appeared after ${Date.now() - startTime}ms`, $timeline)
  3218. }
  3219. observeTimelineItems($timeline)
  3220. })
  3221. })
  3222. }, 'individual tweet timeline parent')
  3223. )
  3224. }
  3225. }
  3226. //#endregion
  3227.  
  3228. //#region Tweak functions
  3229. /**
  3230. * Add an "Add muted word" menu item after the given link which takes you
  3231. * straight to entering a new muted word (by clicking its way through all the
  3232. * individual screens!).
  3233. * @param {HTMLElement} $link
  3234. * @param {string} linkSelector
  3235. */
  3236. async function addAddMutedWordMenuItem($link, linkSelector) {
  3237. log('adding "Add muted word" menu item')
  3238.  
  3239. // Wait for the dropdown to appear on desktop
  3240. if (desktop) {
  3241. $link = await getElement(`#layers div[data-testid="Dropdown"] ${linkSelector}`, {
  3242. name: 'rendered menu item',
  3243. timeout: 100,
  3244. })
  3245. if (!$link) return
  3246. }
  3247.  
  3248. let $addMutedWord = /** @type {HTMLElement} */ ($link.parentElement.cloneNode(true))
  3249. $addMutedWord.classList.add('tnt_menu_item')
  3250. $addMutedWord.querySelector('a').href = PagePaths.ADD_MUTED_WORD
  3251. $addMutedWord.querySelector('span').textContent = getString('ADD_MUTED_WORD')
  3252. $addMutedWord.querySelector('svg').innerHTML = Svgs.MUTE
  3253. $addMutedWord.addEventListener('click', (e) => {
  3254. e.preventDefault()
  3255. addMutedWord()
  3256. })
  3257. $link.parentElement.insertAdjacentElement('beforebegin', $addMutedWord)
  3258. }
  3259.  
  3260. function addCaretMenuListenerForQuoteTweet($tweet) {
  3261. let $caret = /** @type {HTMLElement} */ ($tweet.querySelector('[data-testid="caret"]'))
  3262. if ($caret && !$caret.dataset.tweakNewTwitterListener) {
  3263. $caret.addEventListener('click', () => {
  3264. quotedTweet = getQuotedTweetDetails($tweet, {getText: true})
  3265. })
  3266. $caret.dataset.tweakNewTwitterListener = 'true'
  3267. }
  3268. }
  3269.  
  3270. /**
  3271. * @param {HTMLElement} $blockMenuItem
  3272. */
  3273. async function addMuteQuotesMenuItems($blockMenuItem) {
  3274. log('mutableQuoteTweets: adding "Mute this conversation" and "Turn off Quote Tweets" menu item')
  3275.  
  3276. // Wait for the menu to render properly on desktop
  3277. if (desktop) {
  3278. $blockMenuItem = await getElement(`:scope > div > div > div > ${Selectors.BLOCK_MENU_ITEM}`, {
  3279. context: $blockMenuItem.parentElement,
  3280. name: 'rendered block menu item',
  3281. timeout: 100,
  3282. })
  3283. if (!$blockMenuItem) return
  3284. }
  3285.  
  3286. let $muteQuotes = /** @type {HTMLElement} */ ($blockMenuItem.previousElementSibling.cloneNode(true))
  3287. $muteQuotes.classList.add('tnt_menu_item')
  3288. $muteQuotes.querySelector('span').textContent = getString('MUTE_THIS_CONVERSATION')
  3289. $muteQuotes.addEventListener('click', (e) => {
  3290. e.preventDefault()
  3291. log('mutableQuoteTweets: muting quotes of a tweet', quotedTweet)
  3292. config.mutedQuotes = config.mutedQuotes.concat(quotedTweet)
  3293. storeConfigChanges({mutedQuotes: config.mutedQuotes})
  3294. processCurrentPage()
  3295. // Dismiss the menu
  3296. let $menuLayer = /** @type {HTMLElement} */ ($blockMenuItem.closest('[role="group"]')?.firstElementChild?.firstElementChild)
  3297. if (!$menuLayer) {
  3298. warn('mutableQuoteTweets: could not find menu layer to dismiss menu')
  3299. }
  3300. $menuLayer?.click()
  3301. })
  3302.  
  3303. if (quotedTweet?.quotedBy) {
  3304. let $toggleQuotes = /** @type {HTMLElement} */ ($blockMenuItem.previousElementSibling.cloneNode(true))
  3305. $toggleQuotes.classList.add('tnt_menu_item')
  3306. $toggleQuotes.querySelector('span').textContent = getString(`TURN_OFF_QUOTE_TWEETS`)
  3307. $toggleQuotes.querySelector('svg').innerHTML = Svgs.RETWEETS_OFF
  3308. $toggleQuotes.addEventListener('click', (e) => {
  3309. e.preventDefault()
  3310. log('mutableQuoteTweets: toggling quotes from', quotedTweet.quotedBy)
  3311. if (config.hideQuotesFrom.includes(quotedTweet.quotedBy)) {
  3312. config.hideQuotesFrom = config.hideQuotesFrom.filter(user => user != quotedTweet.quotedBy)
  3313. } else {
  3314. config.hideQuotesFrom = config.hideQuotesFrom.concat(quotedTweet.quotedBy)
  3315. }
  3316. storeConfigChanges({hideQuotesFrom: config.hideQuotesFrom})
  3317. processCurrentPage()
  3318. // Dismiss the menu
  3319. let $menuLayer = /** @type {HTMLElement} */ ($blockMenuItem.closest('[role="group"]')?.firstElementChild?.firstElementChild)
  3320. if (!$menuLayer) {
  3321. warn('mutableQuoteTweets: could not find menu layer to dismiss menu')
  3322. }
  3323. $menuLayer?.click()
  3324. })
  3325. $blockMenuItem.insertAdjacentElement('beforebegin', $toggleQuotes)
  3326. } else {
  3327. warn('mutableQuoteTweets: quotedBy not available when Tweet menu was opened')
  3328. }
  3329.  
  3330. $blockMenuItem.insertAdjacentElement('beforebegin', $muteQuotes)
  3331. }
  3332.  
  3333. async function addMutedWord() {
  3334. if (!document.querySelector('a[href="/settings')) {
  3335. let $settingsAndSupport = /** @type {HTMLElement} */ (document.querySelector('[data-testid="settingsAndSupport"]'))
  3336. $settingsAndSupport?.click()
  3337. }
  3338.  
  3339. for (let path of [
  3340. '/settings',
  3341. '/settings/privacy_and_safety',
  3342. '/settings/mute_and_block',
  3343. '/settings/muted_keywords',
  3344. '/settings/add_muted_keyword',
  3345. ]) {
  3346. let $link = await getElement(`a[href="${path}"]`, {timeout: 500})
  3347. if (!$link) return
  3348. $link.click()
  3349. }
  3350. let $input = await getElement('input[name="keyword"]')
  3351. setTimeout(() => $input.focus(), 100)
  3352. }
  3353.  
  3354. /**
  3355. * Add a "Turn on/off Retweets" menu item to a List's menu.
  3356. * @param {HTMLElement} $switchMenuItem
  3357. */
  3358. async function addToggleListRetweetsMenuItem($switchMenuItem) {
  3359. log('adding "Turn on/off Retweets" menu item')
  3360.  
  3361. // Wait for the menu to render properly on desktop
  3362. if (desktop) {
  3363. $switchMenuItem = await getElement(':scope > div > div > div > [role="menuitem"]', {
  3364. context: $switchMenuItem.parentElement,
  3365. name: 'rendered switch menu item',
  3366. timeout: 100,
  3367. })
  3368. if (!$switchMenuItem) return
  3369. }
  3370.  
  3371. let $toggleRetweets = /** @type {HTMLElement} */ ($switchMenuItem.cloneNode(true))
  3372. $toggleRetweets.classList.add('tnt_menu_item')
  3373. $toggleRetweets.querySelector('span').textContent = getString(`TURN_${config.listRetweets == 'ignore' ? 'OFF' : 'ON'}_RETWEETS`)
  3374. $toggleRetweets.querySelector('svg').innerHTML = config.listRetweets == 'ignore' ? Svgs.RETWEETS_OFF : Svgs.RETWEET
  3375. // Remove subtitle if the cloned menu item has one
  3376. $toggleRetweets.querySelector('div[dir] + div[dir]')?.remove()
  3377. $toggleRetweets.addEventListener('click', (e) => {
  3378. e.preventDefault()
  3379. log('toggling list retweets')
  3380. config.listRetweets = config.listRetweets == 'ignore' ? 'hide' : 'ignore'
  3381. storeConfigChanges({listRetweets: config.listRetweets})
  3382. processCurrentPage()
  3383. // Dismiss the menu
  3384. let $menuLayer = /** @type {HTMLElement} */ ($switchMenuItem.closest('[role="group"]')?.firstElementChild?.firstElementChild)
  3385. if (!$menuLayer) {
  3386. log('could not find menu layer to dismiss menu')
  3387. }
  3388. $menuLayer?.click()
  3389. })
  3390.  
  3391. $switchMenuItem.insertAdjacentElement('beforebegin', $toggleRetweets)
  3392. }
  3393.  
  3394. /**
  3395. * Redirects away from the Home timeline if we're on it and it's been disabled.
  3396. * @returns {boolean} `true` if redirected as a result of this call
  3397. */
  3398. function checkforDisabledHomeTimeline() {
  3399. if (config.disableHomeTimeline && location.pathname == PagePaths.HOME) {
  3400. log(`Home timeline disabled, redirecting to /${config.disabledHomeTimelineRedirect}`)
  3401. let primaryNavSelector = desktop ? Selectors.PRIMARY_NAV_DESKTOP : Selectors.PRIMARY_NAV_MOBILE
  3402. void (async () => {
  3403. let $navLink = await getElement(`${primaryNavSelector} a[href="/${config.disabledHomeTimelineRedirect}"]`, {
  3404. name: `${config.disabledHomeTimelineRedirect} nav link`,
  3405. stopIf: () => location.pathname != PagePaths.HOME,
  3406. })
  3407. if (!$navLink) return
  3408. $navLink.click()
  3409. })()
  3410. return true
  3411. }
  3412. }
  3413.  
  3414. //#region CSS
  3415. const configureCss = (() => {
  3416. let $style
  3417.  
  3418. return function configureCss() {
  3419. $style ??= addStyle('features')
  3420. let cssRules = [`
  3421. .tnt_font_family {
  3422. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  3423. }
  3424. `]
  3425. let hideCssSelectors = ['.HiddenTweet', '.HiddenTweet + [role="separator"]', '.HiddenAd']
  3426. let menuRole = `[role="${desktop ? 'menu' : 'dialog'}"]`
  3427.  
  3428. // Theme colours for custom UI items
  3429. cssRules.push(`
  3430. body.Default {
  3431. --border-color: rgb(239, 243, 244);
  3432. --color: rgb(83, 100, 113);
  3433. --color-emphasis: rgb(15, 20, 25);
  3434. --hover-bg-color: rgb(247, 249, 249);
  3435. }
  3436. body.Dim {
  3437. --border-color: rgb(56, 68, 77);
  3438. --color: rgb(139, 152, 165);
  3439. --color-emphasis: rgb(247, 249, 249);
  3440. --hover-bg-color: rgb(30, 39, 50);
  3441. }
  3442. body.LightsOut {
  3443. --border-color: rgb(47, 51, 54);
  3444. --color: rgb(113, 118, 123);
  3445. --color-emphasis: rgb(247, 249, 249);
  3446. --hover-bg-color: rgb(22, 24, 28);
  3447. }
  3448. .tnt_menu_item:hover { background-color: var(--hover-bg-color) !important; }
  3449. `)
  3450.  
  3451. if (config.alwaysUseLatestTweets && config.hideForYouTimeline) {
  3452. cssRules.push(`
  3453. /* Prevent the For you tab container taking up space */
  3454. body.HomeTimeline nav.TimelineTabs div[role="tablist"] > div:first-child {
  3455. flex-grow: 0;
  3456. flex-shrink: 1;
  3457. /* New layout has margin-right on tabs */
  3458. margin-right: 0;
  3459. }
  3460. /* Hide the For you tab link */
  3461. body.HomeTimeline nav.TimelineTabs div[role="tablist"] > div:first-child > a {
  3462. display: none;
  3463. }
  3464. `)
  3465. }
  3466. if (config.disableTweetTextFormatting) {
  3467. cssRules.push(`
  3468. div[data-testid="tweetText"] span {
  3469. font-style: normal;
  3470. font-weight: normal;
  3471. }
  3472. `)
  3473. }
  3474. if (config.dropdownMenuFontWeight) {
  3475. cssRules.push(`
  3476. [data-testid="${desktop ? 'Dropdown' : 'sheetDialog'}"] [role="menuitem"] [dir] {
  3477. font-weight: normal;
  3478. }
  3479. `)
  3480. }
  3481. if (config.hideBookmarkButton) {
  3482. // Under timeline tweets
  3483. hideCssSelectors.push(
  3484. 'body:not(.Bookmarks) [data-testid="tweet"][tabindex="0"] [role="group"] > div:has(> button[data-testid$="ookmark"])',
  3485. )
  3486. if (!config.showBookmarkButtonUnderFocusedTweets) {
  3487. // Under the focused tweet
  3488. hideCssSelectors.push(
  3489. '[data-testid="tweet"][tabindex="-1"] [role="group"][id^="id__"] > div:has(> button[data-testid$="ookmark"])',
  3490. )
  3491. }
  3492. }
  3493. if (!config.hideExplorePageContents) {
  3494. hideCssSelectors.push(
  3495. // Hide the ad at the top of Explore…
  3496. 'body.Explore [data-testid="eventHero"]',
  3497. // …and its floating button
  3498. 'body.Explore [data-testid="eventHero"] + div',
  3499. )
  3500. }
  3501. if (config.hideListsNav) {
  3502. hideCssSelectors.push(`${menuRole} a[href$="/lists"]`)
  3503. }
  3504. if (config.hideBookmarksNav) {
  3505. hideCssSelectors.push(`${menuRole} a[href$="/bookmarks"]`)
  3506. }
  3507. if (config.hideCommunitiesNav) {
  3508. hideCssSelectors.push(`${menuRole} a[href$="/communities"]`)
  3509. }
  3510. if (config.hideShareTweetButton) {
  3511. hideCssSelectors.push(
  3512. // Under timeline tweets
  3513. `[data-testid="tweet"][tabindex="0"] [role="group"] > div[style]:not(${TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR})`,
  3514. // Under the focused tweet
  3515. `[data-testid="tweet"][tabindex="-1"] [role="group"] > div[style]:not(${TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR})`,
  3516. )
  3517. }
  3518. if (config.hideSubscriptions) {
  3519. hideCssSelectors.push(
  3520. // Subscribe buttons in profile (multiple locations)
  3521. 'body.Profile [role="button"][style*="border-color: rgb(201, 54, 204)"]',
  3522. // Subscriptions count in profile
  3523. 'body.Profile a[href$="/creator-subscriptions/subscriptions"]',
  3524. // Subs tab in profile
  3525. 'body.Profile .SubsTab',
  3526. // Subscribe button in focused tweet
  3527. '[data-testid="tweet"][tabindex="-1"] [data-testid$="-subscribe"]',
  3528. // "Subscribe to" dropdown item (desktop)
  3529. '[data-testid="Dropdown"] > [data-testid="subscribe"]',
  3530. // "Subscribe to" menu item (mobile)
  3531. '[data-testid="sheetDialog"] > [data-testid="subscribe"]',
  3532. // "Subscriber" indicator in replies from subscribers
  3533. '[data-testid="tweet"] [data-testid="icon-subscriber"]',
  3534. // Monetization and Subscriptions items in Settings
  3535. 'body.Settings a[href="/settings/monetization"]',
  3536. 'body.Settings a[href="/settings/manage_subscriptions"]',
  3537. // Subscriptions tab link in Following/Follows
  3538. `body.ProfileFollows.Subscriptions ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:last-child > a`,
  3539. )
  3540. // Subscriptions tab in Following/Follows
  3541. cssRules.push(`
  3542. body.ProfileFollows.Subscriptions ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:last-child {
  3543. flex: 0;
  3544. /* New layout has margin-right on tabs */
  3545. margin-right: 0;
  3546. }
  3547. `)
  3548. }
  3549. if (config.hideMetrics) {
  3550. configureHideMetricsCss(cssRules, hideCssSelectors)
  3551. }
  3552. if (config.hideMoreTweets) {
  3553. hideCssSelectors.push('.SuggestedContent')
  3554. }
  3555. if (config.hideCommunitiesNav) {
  3556. hideCssSelectors.push(`${menuRole} a[href$="/communities"]`)
  3557. }
  3558. if (config.hideGrokNav) {
  3559. hideCssSelectors.push(
  3560. // In menus
  3561. `${menuRole} a[href$="/i/grok"]`,
  3562. // Grok Actions button
  3563. `button[aria-label="${getString('GROK_ACTIONS')}"]`,
  3564. // "Generate image" button in the Tweet editor
  3565. 'button[data-testid="grokImgGen"]',
  3566. // Any Grok buttons we manually tag
  3567. '.GrokButton',
  3568. // Grok suggested prompts in Tweets
  3569. '[data-testid="tweet"] [data-testid^="followups_"]',
  3570. '[data-testid="tweet"] [data-testid^="followups_"] + nav',
  3571. // Profile Summary button
  3572. `button[aria-label="${getString('PROFILE_SUMMARY')}"]`,
  3573. // Grok summary at the top of search results
  3574. 'body.Search [data-testid="primaryColumn"] > div > div:has(> [data-testid="followups_search"])',
  3575. )
  3576. }
  3577. if (config.hideMonetizationNav) {
  3578. hideCssSelectors.push(`${menuRole} a[href$="/i/monetization"]`)
  3579. }
  3580. if (config.hideAdsNav) {
  3581. hideCssSelectors.push(`${menuRole} a:is([href*="ads.twitter.com"], [href*="ads.x.com"])`)
  3582. }
  3583. if (config.hideJobsNav) {
  3584. hideCssSelectors.push(
  3585. // Jobs navigation item
  3586. `${menuRole} a[href="/jobs"]`,
  3587. // Jobs section in profiles
  3588. '.Profile [data-testid="jobs"]',
  3589. )
  3590. }
  3591. if (config.hideTweetAnalyticsLinks) {
  3592. hideCssSelectors.push('.AnalyticsButton')
  3593. }
  3594. if (config.hideTwitterBlueUpsells) {
  3595. hideCssSelectors.push(
  3596. // Manually-tagged upsells
  3597. '.PremiumUpsell',
  3598. // Premium/Verified menu items
  3599. `${menuRole} a:is([href^="/i/premium"], [href^="/i/verified"])`,
  3600. // In new More dialog
  3601. `${Selectors.MORE_DIALOG} a:is([href^="/i/premium"], [href^="/i/verified"])`,
  3602. // Analytics menu item
  3603. `${menuRole} a[href="/i/account_analytics"]`,
  3604. // "Highlight on your profile" on your tweets
  3605. '[role="menuitem"][data-testid="highlightUpsell"]',
  3606. // "Edit" upsell on recent tweets
  3607. '[role="menuitem"][data-testid="editWithPremium"]',
  3608. // Premium item in Settings
  3609. 'body.Settings a[href^="/i/premium"]',
  3610. // Misc upsells in your own profile
  3611. `.OwnProfile ${Selectors.PRIMARY_COLUMN} a[href^="/i/premium"]`,
  3612. // Unlock Analytics button in your own profile
  3613. '.OwnProfile [data-testid="analytics-preview"]',
  3614. // Button in Communities header
  3615. `body.Communities ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} a:is([href^="/i/premium"], [href^="/i/verified"])`,
  3616. // "This profile is verified" upsell
  3617. '[data-testid="verified_profile_upsell"]',
  3618. // Get Premium Analytics upsell
  3619. '[data-testid="profileAnalyticsUpsell"]',
  3620. )
  3621. // Hide Highlights and Articles tabs in your own profile if you don't have Premium
  3622. let profileTabsList = `body.OwnProfile:not(.PremiumProfile) ${Selectors.PRIMARY_COLUMN} nav div[role="tablist"]`
  3623. let upsellTabLinks = 'a:is([href$="/highlights"], [href$="/articles"], [href$="/highlights?mx=1"], [href$="/articles?mx=1"])'
  3624. cssRules.push(`
  3625. ${profileTabsList} > div:has(> ${upsellTabLinks}) {
  3626. flex: 0;
  3627. /* New layout has margin-right on tabs */
  3628. margin-right: 0;
  3629. }
  3630. ${profileTabsList} > div > ${upsellTabLinks} {
  3631. display: none;
  3632. }
  3633. `)
  3634. // Hide upsell on the Likes tab in your own profile
  3635. cssRules.push(`
  3636. body.OwnProfile ${Selectors.PRIMARY_COLUMN} nav + div:has(a[href^="/i/premium"]) {
  3637. display: none;
  3638. }
  3639. `)
  3640. }
  3641. if (config.hideVerifiedNotificationsTab) {
  3642. cssRules.push(`
  3643. body.Notifications ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:nth-child(2),
  3644. body.ProfileFollows ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:nth-child(1) {
  3645. flex: 0;
  3646. /* New layout has margin-right on tabs */
  3647. margin-right: 0;
  3648. }
  3649. body.Notifications ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:nth-child(2) > a,
  3650. body.ProfileFollows ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:nth-child(1) > a {
  3651. display: none;
  3652. }
  3653. `)
  3654. }
  3655. if (config.hideViews) {
  3656. hideCssSelectors.push(
  3657. // "Views" under the focused tweet
  3658. '[data-testid="tweet"][tabindex="-1"] div[dir] + div[aria-hidden="true"]:nth-child(2):nth-last-child(2)',
  3659. '[data-testid="tweet"][tabindex="-1"] div[dir] + div[aria-hidden="true"]:nth-child(2):nth-last-child(2) + div[dir]:last-child'
  3660. )
  3661. }
  3662. if (config.hideWhoToFollowEtc) {
  3663. hideCssSelectors.push(`body.Profile ${Selectors.PRIMARY_COLUMN} aside[role="complementary"]`)
  3664. }
  3665. if (config.reducedInteractionMode) {
  3666. hideCssSelectors.push(
  3667. '[data-testid="tweet"] [role="group"]',
  3668. 'body.Tweet [data-testid="tweet"] + div > div [role="group"]',
  3669. )
  3670. }
  3671. if (config.restoreLinkHeadlines) {
  3672. hideCssSelectors.push(
  3673. // Existing headline overlaid on the card
  3674. '.tnt_overlay_headline',
  3675. // From <domain> link after the card
  3676. 'div[data-testid="card.wrapper"] + a',
  3677. )
  3678. } else {
  3679. hideCssSelectors.push('.tnt_link_headline')
  3680. }
  3681. if (config.restoreQuoteTweetsLink || config.restoreOtherInteractionLinks) {
  3682. cssRules.push(`
  3683. #tntInteractionLinks a {
  3684. text-decoration: none;
  3685. color: var(--color);
  3686. }
  3687. #tntInteractionLinks a:hover span:last-child {
  3688. text-decoration: underline;
  3689. }
  3690. #tntQuoteTweetCount, #tntRetweetCount, #tntLikeCount {
  3691. margin-right: 2px;
  3692. font-weight: 700;
  3693. color: var(--color-emphasis);
  3694. }
  3695. /* Replaces the "View post engagements" link under your own tweets */
  3696. .AnalyticsButton {
  3697. display: none;
  3698. }
  3699. `)
  3700. } else {
  3701. hideCssSelectors.push('#tntInteractionLinks')
  3702. }
  3703. if (!config.restoreQuoteTweetsLink) {
  3704. hideCssSelectors.push('#tntQuoteTweetsLink')
  3705. }
  3706. if (!config.restoreOtherInteractionLinks) {
  3707. hideCssSelectors.push('#tntRetweetsLink', '#tntLikesLink')
  3708. }
  3709. if (config.tweakQuoteTweetsPage) {
  3710. // Hide the quoted tweet, which is repeated in every quote tweet
  3711. hideCssSelectors.push('body.QuoteTweets [data-testid="tweet"] [aria-labelledby] > div:last-child')
  3712. }
  3713. if (config.twitterBlueChecks == 'hide') {
  3714. hideCssSelectors.push('.tnt_blue_check')
  3715. }
  3716. if (config.twitterBlueChecks == 'replace') {
  3717. cssRules.push(`
  3718. :is(${Selectors.VERIFIED_TICK}, svg[data-testid="verificationBadge"]).tnt_blue_check path {
  3719. d: path("${Svgs.BLUE_LOGO_PATH}");
  3720. }
  3721. `)
  3722. }
  3723.  
  3724. if (shouldShowSeparatedTweetsTab()) {
  3725. if (hasNewLayout()) {
  3726. // The new layout only has colour to distinguish the active tab
  3727. cssRules.push(`
  3728. body:not(.SeparatedTweets) #tnt_separated_tweets_tab > a > div > div,
  3729. body.HomeTimeline.SeparatedTweets ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:not(#tnt_separated_tweets_tab) > a > div > div {
  3730. color: var(--color) !important;
  3731. }
  3732. body.SeparatedTweets #tnt_separated_tweets_tab > a > div > div {
  3733. color: var(--color-emphasis) !important;
  3734. }
  3735. body.Desktop #tnt_separated_tweets_tab:hover > a > div > div {
  3736. color: var(--color-emphasis) !important;
  3737. }
  3738. `)
  3739. } else {
  3740. cssRules.push(`
  3741. body.Default {
  3742. --tab-hover: rgba(15, 20, 25, 0.1);
  3743. }
  3744. body.Dim {
  3745. --tab-hover: rgba(247, 249, 249, 0.1);
  3746. }
  3747. body.LightsOut {
  3748. --tab-hover: rgba(231, 233, 234, 0.1);
  3749. }
  3750. body.Desktop #tnt_separated_tweets_tab:hover,
  3751. body.Mobile:not(.SeparatedTweets) #tnt_separated_tweets_tab:hover,
  3752. body.Mobile #tnt_separated_tweets_tab:active {
  3753. background-color: var(--tab-hover);
  3754. }
  3755. body:not(.SeparatedTweets) #tnt_separated_tweets_tab > a > div > div,
  3756. body.HomeTimeline.SeparatedTweets ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav div[role="tablist"] > div:not(#tnt_separated_tweets_tab) > a > div > div {
  3757. font-weight: normal !important;
  3758. color: var(--color) !important;
  3759. }
  3760. body.SeparatedTweets #tnt_separated_tweets_tab > a > div > div {
  3761. font-weight: bold;
  3762. color: var(--color-emphasis); !important;
  3763. }
  3764. body:not(.SeparatedTweets) #tnt_separated_tweets_tab > a > div > div > div,
  3765. 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 {
  3766. height: 0 !important;
  3767. }
  3768. body.SeparatedTweets #tnt_separated_tweets_tab > a > div > div > div {
  3769. height: 4px !important;
  3770. min-width: 56px;
  3771. width: 100%;
  3772. position: absolute;
  3773. bottom: 0;
  3774. border-radius: 9999px;
  3775. }
  3776. `)
  3777. }
  3778. }
  3779.  
  3780. if (hasNewLayout() && config.tweakNewLayout) {
  3781. cssRules.push(`
  3782. /* Make the image button first in the Tweet editor toolbar again */
  3783. [data-testid="toolBar"] [role="tablist"] > [role="presentation"] {
  3784. order: 1;
  3785. }
  3786. [data-testid="toolBar"] [role="tablist"] > [role="presentation"]:has(input[data-testid="fileInput"]) {
  3787. order: 0;
  3788. }
  3789. `)
  3790. if (config.replaceLogo) {
  3791. cssRules.push(`
  3792. /* Add theme colour back to Tweet editor toolbar buttons */
  3793. [data-testid="toolBar"] [role="tablist"] > [role="presentation"] svg {
  3794. fill: var(--theme-color);
  3795. }
  3796. `)
  3797. }
  3798. }
  3799.  
  3800. //#region Desktop-only
  3801. if (desktop) {
  3802. if (hasNewLayout() && config.tweakNewLayout) {
  3803. cssRules.push(`
  3804. /* Realign nav items to the top */
  3805. header[role="banner"] > div > div > div {
  3806. justify-content: flex-start;
  3807. }
  3808. /* Restore size and constrast of main nav icons and More button */
  3809. ${Selectors.PRIMARY_NAV_DESKTOP} > :is(a, button) svg {
  3810. width: 1.75rem !important;
  3811. height: 1.75rem !important;
  3812. fill: var(--color-emphasis) !important;
  3813. }
  3814. /* Restore contrast of main nav text when expanded */
  3815. ${Selectors.PRIMARY_NAV_DESKTOP} > :is(a, button) div[dir]:not([aria-live]) {
  3816. color: var(--color-emphasis) !important;
  3817. }
  3818. /* Give other nav button icons more contrast too */
  3819. header[role="banner"] button svg {
  3820. fill: var(--color-emphasis) !important;
  3821. }
  3822. /* Make the Tweet button larger */
  3823. [data-testid="SideNav_NewTweet_Button"] {
  3824. min-width: 49px;
  3825. min-height: 49px;
  3826. }
  3827. /* Move the account switcher back to the bottom */
  3828. header[role="banner"] > div > div > div > div:last-child {
  3829. flex: 1;
  3830. justify-content: space-between;
  3831. }
  3832. /* Restore primary column borders */
  3833. header[role="banner"] > div > div > div {
  3834. border-right: 1px solid var(--border-color);
  3835. }
  3836. ${Selectors.PRIMARY_COLUMN} {
  3837. border-right: 1px solid var(--border-color);
  3838. }
  3839. /* Left-align main contents and stop it taking up all available space */
  3840. main {
  3841. align-items: flex-start !important;
  3842. flex-grow: 0 !important;
  3843. }
  3844. /* Remove the gap between main contents and sidebar */
  3845. main > div > div > div {
  3846. justify-content: normal !important;
  3847. }
  3848. /* Restore the sidebar to its old width */
  3849. ${Selectors.SIDEBAR},
  3850. ${Selectors.SIDEBAR} > div > div,
  3851. body.HomeTimeline ${Selectors.SIDEBAR_WRAPPERS} > div > div:first-child,
  3852. ${Selectors.SIDEBAR_WRAPPERS} > div:first-child {
  3853. width: 350px !important;
  3854. }
  3855. /* Center content */
  3856. div[data-at-shortcutkeys] {
  3857. justify-content: center;
  3858. }
  3859. `)
  3860. if (config.replaceLogo) {
  3861. // TODO Manually patch Tweet button SVG in Safari
  3862. cssRules.push(`
  3863. /* Restore theme colour in nav item pips */
  3864. ${Selectors.PRIMARY_NAV_DESKTOP} > :is(a[href^="/notifications"], a[href="/messages"]) div[aria-live],
  3865. ${Selectors.MORE_DIALOG} :is(a[href^="/notifications"], a[href="/messages"]) div[aria-live],
  3866. /* Restore theme colour in profile switcher other accounts have notifications pip */
  3867. button[data-testid="SideNav_AccountSwitcher_Button"] > div > div[aria-label],
  3868. /* Restore theme colour in account switcher notifications pips */
  3869. [data-testid="HoverCard"] button[data-testid="UserCell"] div[aria-live] {
  3870. background-color: var(--theme-color);
  3871. }
  3872. /* Replace the plus icon in the Tweet button with the feather */
  3873. [data-testid="SideNav_NewTweet_Button"] path[d="${Svgs.PLUS_PATH}"] {
  3874. d: path("${Svgs.TWITTER_FEATHER_PLUS_PATH}");
  3875. }
  3876. `)
  3877. }
  3878. }
  3879. if (hasNewLayout() && config.hideToggleNavigation) {
  3880. hideCssSelectors.push('header[role="banner"] > div > div > div > div:first-child > button')
  3881. }
  3882. if (config.navDensity == 'comfortable' || config.navDensity == 'compact') {
  3883. cssRules.push(`
  3884. header nav > a,
  3885. header nav > div[data-testid="AppTabBar_More_Menu"] {
  3886. padding-top: 0 !important;
  3887. padding-bottom: 0 !important;
  3888. }
  3889. `)
  3890. }
  3891. if (config.navDensity == 'compact') {
  3892. cssRules.push(`
  3893. header nav > a > div,
  3894. header nav > div[data-testid="AppTabBar_More_Menu"] > div {
  3895. padding-top: 6px !important;
  3896. padding-bottom: 6px !important;
  3897. }
  3898. `)
  3899. }
  3900. if (config.hideSeeNewTweets) {
  3901. hideCssSelectors.push(`body.HomeTimeline ${Selectors.PRIMARY_COLUMN} > div > div:first-child > div[style^="transform"]`)
  3902. }
  3903. if (config.hideTimelineTweetBox) {
  3904. hideCssSelectors.push(`body.HomeTimeline ${Selectors.PRIMARY_COLUMN} .TweetBox`)
  3905. }
  3906. if (config.disableHomeTimeline) {
  3907. hideCssSelectors.push(`${Selectors.PRIMARY_NAV_DESKTOP} a[href="/home"]`)
  3908. }
  3909. if (config.hideNotifications != 'ignore') {
  3910. // Hide notification badges and indicators
  3911. hideCssSelectors.push(
  3912. // Notifications & Messages in primary nav
  3913. `${Selectors.PRIMARY_NAV_DESKTOP} > :is(a[href^="/notifications"], a[href="/messages"]) div[aria-live]`,
  3914. // Notifications & Messages in the More dialog in the new layout
  3915. `${Selectors.MORE_DIALOG} :is(a[href^="/notifications"], a[href="/messages"]) div[aria-live]`,
  3916. // Account switcher
  3917. 'button[data-testid="SideNav_AccountSwitcher_Button"] > div > div[aria-label]',
  3918. // Account switcher accounts
  3919. '[data-testid="HoverCard"] button[data-testid="UserCell"] div[aria-live]',
  3920. // Messages drawer title
  3921. '[data-testid="DMDrawerHeader"] h2 svg[role="img"]'
  3922. )
  3923. if (config.hideNotifications == 'hide') {
  3924. hideCssSelectors.push(
  3925. // Nav item
  3926. `${Selectors.PRIMARY_NAV_DESKTOP} a[href^="/notifications"]`,
  3927. // More dialog item
  3928. `${Selectors.MORE_DIALOG} a[href^="/notifications"]`,
  3929. )
  3930. }
  3931. }
  3932. if (config.fullWidthContent) {
  3933. cssRules.push(`
  3934. /* Use full width when the sidebar is visible */
  3935. body.Sidebar${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN},
  3936. body.Sidebar${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} > div:first-child > div:last-child {
  3937. max-width: 990px;
  3938. }
  3939. /* Make the "What's happening" input keep its original width */
  3940. body.HomeTimeline ${Selectors.PRIMARY_COLUMN} > div:first-child > div:nth-of-type(3) div[role="progressbar"] + div {
  3941. max-width: 598px;
  3942. }
  3943. /* Use full width when the sidebar is not visible */
  3944. body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} header[role="banner"] {
  3945. flex-grow: 0;
  3946. }
  3947. body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} main[role="main"] > div {
  3948. width: 100%;
  3949. }
  3950. body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} {
  3951. max-width: unset;
  3952. width: 100%;
  3953. }
  3954. body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} > div:first-child > div:first-child div,
  3955. body:not(.Sidebar)${FULL_WIDTH_BODY_PSEUDO} ${Selectors.PRIMARY_COLUMN} > div:first-child > div:last-child {
  3956. max-width: unset;
  3957. }
  3958. `)
  3959. if (!config.fullWidthMedia) {
  3960. // Make media & cards keep their original width
  3961. cssRules.push(`
  3962. 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) {
  3963. max-width: 504px;
  3964. }
  3965. `)
  3966. }
  3967. // Hide the sidebar when present
  3968. hideCssSelectors.push(`body.Sidebar${FULL_WIDTH_BODY_PSEUDO} ${Selectors.SIDEBAR}`)
  3969. }
  3970. if (config.hideAccountSwitcher) {
  3971. cssRules.push(`
  3972. header[role="banner"] > div > div > div > div:last-child {
  3973. flex-shrink: 1 !important;
  3974. align-items: flex-end !important;
  3975. }
  3976. `)
  3977. hideCssSelectors.push(
  3978. '[data-testid="SideNav_AccountSwitcher_Button"] > div:first-child:not(:only-child)',
  3979. '[data-testid="SideNav_AccountSwitcher_Button"] > div:first-child + div',
  3980. )
  3981. }
  3982. if (config.hideExplorePageContents) {
  3983. hideCssSelectors.push(
  3984. // Tabs
  3985. `body.Explore ${Selectors.DESKTOP_TIMELINE_HEADER} nav`,
  3986. // Content
  3987. `body.Explore ${Selectors.TIMELINE}`,
  3988. )
  3989. }
  3990. if (config.hideAdsNav) {
  3991. // In new More dialog
  3992. hideCssSelectors.push(`${Selectors.MORE_DIALOG} a:is([href*="ads.twitter.com"], [href*="ads.x.com"])`)
  3993. }
  3994. if (config.hideComposeTweet) {
  3995. hideCssSelectors.push('[data-testid="SideNav_NewTweet_Button"]')
  3996. }
  3997. if (config.hideGrokNav) {
  3998. hideCssSelectors.push(
  3999. `${Selectors.PRIMARY_NAV_DESKTOP} a[href$="/i/grok"]`,
  4000. // In new More dialog
  4001. `${Selectors.MORE_DIALOG} a[href$="/i/grok"]`,
  4002. // Grok drawer
  4003. 'div[data-testid="GrokDrawer"]',
  4004. )
  4005. }
  4006. if (config.hideJobsNav) {
  4007. hideCssSelectors.push(
  4008. `${Selectors.PRIMARY_NAV_DESKTOP} a[href="/jobs"]`,
  4009. // In new More dialog
  4010. `${Selectors.MORE_DIALOG} a[href="/jobs"]`,
  4011. )
  4012. }
  4013. if (config.hideListsNav) {
  4014. hideCssSelectors.push(
  4015. `${Selectors.PRIMARY_NAV_DESKTOP} a[href$="/lists"]`,
  4016. // In new More dialog
  4017. `${Selectors.MORE_DIALOG} a[href$="/lists"]`,
  4018. )
  4019. }
  4020. if (config.hideMonetizationNav) {
  4021. // In new More dialog
  4022. hideCssSelectors.push(`${Selectors.MORE_DIALOG} a[href$="/i/monetization"]`)
  4023. }
  4024. if (config.hideProNav) {
  4025. hideCssSelectors.push(`${menuRole} a:is([href*="pro.twitter.com"], [href*="pro.x.com"])`)
  4026. }
  4027. if (config.hideSpacesNav) {
  4028. hideCssSelectors.push(
  4029. `${menuRole} a[href="/i/spaces/start"]`,
  4030. // In new More dialog
  4031. `${Selectors.MORE_DIALOG} a[href="/i/spaces/start"]`,
  4032. )
  4033. }
  4034. if (config.hideTwitterBlueUpsells) {
  4035. hideCssSelectors.push(
  4036. // Nav items
  4037. `${Selectors.PRIMARY_NAV_DESKTOP} a:is([href^="/i/premium"], [href^="/i/verified"])`,
  4038. // Search sidebar Radar upsell
  4039. `body.Search ${Selectors.SIDEBAR_WRAPPERS} > div:first-child:has(a[href="/i/radar"])`,
  4040. `body.Search ${Selectors.SIDEBAR_WRAPPERS} > div:first-child:has(a[href="/i/radar"]) + div:empty`,
  4041. // Premium link in hovercard
  4042. '[data-testid="HoverCard"] a[href^="/i/premium"]',
  4043. )
  4044. }
  4045. if (config.hideSidebarContent) {
  4046. // Only show the first sidebar item by default
  4047. // Re-show subsequent non-algorithmic sections on specific pages
  4048. cssRules.push(`
  4049. body.HomeTimeline ${Selectors.SIDEBAR_WRAPPERS} > div > div:not(:first-of-type) {
  4050. display: none;
  4051. }
  4052. ${Selectors.SIDEBAR_WRAPPERS} > div:not(:first-of-type) {
  4053. display: none;
  4054. }
  4055. body.Search ${Selectors.SIDEBAR_WRAPPERS} > div:nth-of-type(2) {
  4056. display: block;
  4057. }
  4058. /* Radar upsell in Search uses the first item and adds a second one for spacing */
  4059. body.Search ${Selectors.SIDEBAR_WRAPPERS}:has(a[href="/i/radar"]) > div:first-of-type,
  4060. body.Search ${Selectors.SIDEBAR_WRAPPERS}:has(a[href="/i/radar"]) > div:nth-of-type(2):empty {
  4061. display: none;
  4062. }
  4063. body.Search ${Selectors.SIDEBAR_WRAPPERS}:has(a[href="/i/radar"]) > div:nth-of-type(3),
  4064. body.Search ${Selectors.SIDEBAR_WRAPPERS}:has(a[href="/i/radar"]) > div:nth-of-type(4) {
  4065. display: block;
  4066. }
  4067. `)
  4068. if (config.showRelevantPeople) {
  4069. cssRules.push(`
  4070. body.Tweet ${Selectors.SIDEBAR_WRAPPERS} > div:is(:nth-of-type(2), :nth-of-type(3)) {
  4071. display: block;
  4072. }
  4073. `)
  4074. }
  4075. hideCssSelectors.push(`body.HideSidebar ${Selectors.SIDEBAR}`)
  4076. } else if (config.hideTwitterBlueUpsells) {
  4077. // Hide "Subscribe to premium" individually
  4078. hideCssSelectors.push(
  4079. `body.HomeTimeline ${Selectors.SIDEBAR_WRAPPERS} > div > div:nth-of-type(3)`
  4080. )
  4081. }
  4082. if (config.hideShareTweetButton) {
  4083. hideCssSelectors.push(
  4084. // In media modal
  4085. `[aria-modal="true"] div > div:first-of-type [role="group"] > div[style]:not([role]):not(${TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR})`,
  4086. )
  4087. }
  4088. if (config.hideExploreNav) {
  4089. // When configured, hide Explore only when the sidebar is showing, or
  4090. // when on a page full-width content is enabled on.
  4091. let bodySelector = `${config.hideExploreNavWithSidebar ? `body.Sidebar${config.fullWidthContent ? `:not(${FULL_WIDTH_BODY_PSEUDO})` : ''} ` : ''}`
  4092. hideCssSelectors.push(
  4093. `${bodySelector}${Selectors.PRIMARY_NAV_DESKTOP} a[href="/explore"]`,
  4094. // In new More dialog
  4095. `${Selectors.MORE_DIALOG} a[href="/explore"]`,
  4096. )
  4097. }
  4098. if (config.hideBookmarksNav) {
  4099. hideCssSelectors.push(
  4100. `${Selectors.PRIMARY_NAV_DESKTOP} a[href="/i/bookmarks"]`,
  4101. // In new More dialog
  4102. `${Selectors.MORE_DIALOG} a[href="/i/bookmarks"]`,
  4103. )
  4104. }
  4105. if (config.hideCommunitiesNav) {
  4106. hideCssSelectors.push(
  4107. `${Selectors.PRIMARY_NAV_DESKTOP} a[href$="/communities"]`,
  4108. // In new More dialog
  4109. `${Selectors.MORE_DIALOG} a[href$="/communities"]`,
  4110. )
  4111. }
  4112. if (config.hideMessagesDrawer) {
  4113. cssRules.push(`div[data-testid="DMDrawer"] { visibility: hidden; }`)
  4114. }
  4115. if (config.hideViews) {
  4116. hideCssSelectors.push(
  4117. // Under timeline tweets
  4118. '[data-testid="tweet"][tabindex="0"] [role="group"] > div:has(> a[href$="/analytics"])',
  4119. // In media modal
  4120. '[aria-modal="true"] > div > div:first-of-type [role="group"] > div:has(> a[href$="/analytics"])',
  4121. )
  4122. }
  4123. if (config.retweets != 'separate' && config.quoteTweets != 'separate') {
  4124. hideCssSelectors.push('#tnt_separated_tweets_tab')
  4125. }
  4126. }
  4127. //#endregion
  4128.  
  4129. //#region Mobile only
  4130. if (mobile) {
  4131. if (hasNewLayout() && config.tweakNewLayout) {
  4132. cssRules.push(`
  4133. /* Remove new padding from profile details and the tab bar (this has to be accidental) */
  4134. body.Profile ${Selectors.PRIMARY_COLUMN} > div > div > div > div > div > div > div > div {
  4135. padding-left: 0;
  4136. padding-right: 0;
  4137. }
  4138. `)
  4139. if (config.replaceLogo) {
  4140. cssRules.push(`
  4141. /* Restore theme colour in nav item pips */
  4142. ${Selectors.PRIMARY_NAV_MOBILE} > :is(a[href^="/notifications"], a[href="/messages"]) div[aria-label],
  4143. /* Restore theme colour in profile button other accounts have notifications pip */
  4144. button[data-testid="DashButton_ProfileIcon_Link"] div[aria-label],
  4145. /* Restore theme colour in account switcher notifications pips */
  4146. [role="dialog"] [data-testid^="UserAvatar-Container"] div[dir] {
  4147. background-color: var(--theme-color);
  4148. }
  4149. `)
  4150. }
  4151. }
  4152. if (config.disableHomeTimeline) {
  4153. hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href="/home"]`)
  4154. }
  4155. if (config.hideComposeTweet) {
  4156. hideCssSelectors.push('[data-testid="FloatingActionButtons_Tweet_Button"]')
  4157. }
  4158. if (config.hideNotifications != 'ignore') {
  4159. // Hide notification badges and indicators
  4160. hideCssSelectors.push(
  4161. // Notifications & Messages in primary nav
  4162. `${Selectors.PRIMARY_NAV_MOBILE} > :is(a[href^="/notifications"], a[href="/messages"]) div[aria-label]`,
  4163. // Account switcher
  4164. `button[data-testid="DashButton_ProfileIcon_Link"] div[aria-label]`,
  4165. // Account switcher accounts
  4166. '[role="dialog"] [data-testid^="UserAvatar-Container"] div[dir]',
  4167. )
  4168. if (config.hideNotifications == 'hide') {
  4169. hideCssSelectors.push(
  4170. // Nav item
  4171. `${Selectors.PRIMARY_NAV_MOBILE} a[href^="/notifications"]`
  4172. )
  4173. }
  4174. }
  4175. if (config.hideSeeNewTweets) {
  4176. hideCssSelectors.push(`body.HomeTimeline ${Selectors.MOBILE_TIMELINE_HEADER} ~ div[style^="transform"]:last-child`)
  4177. }
  4178. if (config.hideExplorePageContents) {
  4179. // Hide explore page contents so we don't get a brief flash of them
  4180. // before automatically switching the page to search mode.
  4181. hideCssSelectors.push(
  4182. // Tabs
  4183. `body.Explore ${Selectors.MOBILE_TIMELINE_HEADER} > div > div:nth-of-type(2)`,
  4184. // Content
  4185. `body.Explore ${Selectors.TIMELINE}`,
  4186. )
  4187. }
  4188. if (config.hideGrokNav) {
  4189. hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href="/i/grok"]`)
  4190. }
  4191. if (config.hideCommunitiesNav) {
  4192. hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href$="/communities"]`)
  4193. }
  4194. if (config.hideMessagesBottomNavItem) {
  4195. hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href="/messages"]`)
  4196. }
  4197. if (config.hideJobsNav) {
  4198. hideCssSelectors.push(`${Selectors.PRIMARY_NAV_MOBILE} a[href="/jobs"]`)
  4199. }
  4200. if (config.hideTwitterBlueUpsells) {
  4201. hideCssSelectors.push(
  4202. `${Selectors.PRIMARY_NAV_MOBILE} a[href^="/i/premium"]`,
  4203. `${Selectors.MOBILE_TIMELINE_HEADER} a[href^="/i/premium"]`,
  4204. )
  4205. }
  4206. if (config.hideShareTweetButton) {
  4207. hideCssSelectors.push(
  4208. // In media viewer and media modal
  4209. `body:is(.MediaViewer, .MobileMedia) [role="group"] > div[style]:not(${TWITTER_MEDIA_ASSIST_BUTTON_SELECTOR})`,
  4210. )
  4211. }
  4212. if (config.hideViews) {
  4213. hideCssSelectors.push(
  4214. // Under timeline tweets
  4215. '[data-testid="tweet"][tabindex="0"] [role="group"] > div:has(> a[href$="/analytics"])',
  4216. // In media viewer and media modal
  4217. 'body:is(.MediaViewer, .MobileMedia) [role="group"] > div:has(> a[href$="/analytics"])',
  4218. )
  4219. }
  4220. }
  4221. //#endregion
  4222.  
  4223. if (hideCssSelectors.length > 0) {
  4224. cssRules.push(`
  4225. ${hideCssSelectors.join(',\n')} {
  4226. display: none !important;
  4227. }
  4228. `)
  4229. }
  4230.  
  4231. $style.textContent = cssRules.map(dedent).join('\n')
  4232. }
  4233. })()
  4234.  
  4235. function configureFont() {
  4236. if (!fontFamilyRule) {
  4237. warn('no fontFamilyRule found for configureFont to use')
  4238. return
  4239. }
  4240.  
  4241. if (config.dontUseChirpFont) {
  4242. if (fontFamilyRule.style.fontFamily.includes('TwitterChirp')) {
  4243. fontFamilyRule.style.fontFamily = fontFamilyRule.style.fontFamily.replace(/"?TwitterChirp"?, ?/, '')
  4244. log('disabled Chirp font')
  4245. }
  4246. } else if (!fontFamilyRule.style.fontFamily.includes('TwitterChirp')) {
  4247. fontFamilyRule.style.fontFamily = `"TwitterChirp", ${fontFamilyRule.style.fontFamily}`
  4248. log(`enabled Chirp font`)
  4249. }
  4250. }
  4251.  
  4252. /**
  4253. * @param {string[]} cssRules
  4254. * @param {string[]} hideCssSelectors
  4255. */
  4256. function configureHideMetricsCss(cssRules, hideCssSelectors) {
  4257. if (config.hideFollowingMetrics) {
  4258. // User profile hover card and page metrics
  4259. hideCssSelectors.push(
  4260. ':is(#layers, body.Profile) a:is([href$="/following"], [href$="/verified_followers"]) > span:first-child'
  4261. )
  4262. // Fix display of whitespace after hidden metrics
  4263. cssRules.push(
  4264. ':is(#layers, body.Profile) a:is([href$="/following"], [href$="/verified_followers"]) { white-space: pre-line; }'
  4265. )
  4266. }
  4267.  
  4268. if (config.hideTotalTweetsMetrics) {
  4269. // Metrics under username header on profile pages
  4270. hideCssSelectors.push(`
  4271. body.Profile ${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} > div > div:first-of-type h2 + div[dir]
  4272. `)
  4273. }
  4274.  
  4275. let timelineMetricSelectors = [
  4276. config.hideReplyMetrics && '[data-testid="reply"]',
  4277. config.hideRetweetMetrics && '[data-testid$="retweet"]',
  4278. config.hideLikeMetrics && '[data-testid$="like"]',
  4279. config.hideBookmarkMetrics && '[data-testid$="bookmark"], [data-testid$="removeBookmark"]',
  4280. ].filter(Boolean).join(', ')
  4281.  
  4282. if (timelineMetricSelectors) {
  4283. cssRules.push(
  4284. `[role="group"] button:is(${timelineMetricSelectors}) span { visibility: hidden; }`
  4285. )
  4286. }
  4287.  
  4288. if (config.hideQuoteTweetMetrics) {
  4289. hideCssSelectors.push('#tntQuoteTweetCount')
  4290. }
  4291. if (config.hideRetweetMetrics) {
  4292. hideCssSelectors.push('#tntRetweetCount')
  4293. }
  4294. if (config.hideLikeMetrics) {
  4295. hideCssSelectors.push('#tntLikeCount')
  4296. }
  4297. }
  4298.  
  4299. const configureCustomCss = (() => {
  4300. let $style
  4301.  
  4302. return function configureCustomCss() {
  4303. if (config.customCss) {
  4304. $style ??= addStyle('custom')
  4305. $style.textContent = config.customCss
  4306. } else {
  4307. $style?.remove()
  4308. }
  4309. }
  4310. })()
  4311.  
  4312. /**
  4313. * CSS which depends on anything we need to get from the page.
  4314. */
  4315. const configureDynamicCss = (() => {
  4316. let $style
  4317.  
  4318. return function configureDynamicCss() {
  4319. $style ??= addStyle('dynamic')
  4320. let cssRules = []
  4321.  
  4322. if (fontSize != null && config.navBaseFontSize) {
  4323. cssRules.push(`
  4324. ${Selectors.PRIMARY_NAV_DESKTOP} div[dir] span { font-size: ${fontSize}; font-weight: normal; }
  4325. ${Selectors.PRIMARY_NAV_DESKTOP} div[dir] { margin-top: -4px; }
  4326. `)
  4327. }
  4328.  
  4329. if (filterBlurRule != null && config.unblurSensitiveContent) {
  4330. cssRules.push(`
  4331. ${filterBlurRule.selectorText} {
  4332. filter: none !important;
  4333. }
  4334. ${filterBlurRule.selectorText} + div {
  4335. display: none !important;
  4336. }
  4337. `)
  4338. }
  4339.  
  4340. $style.textContent = cssRules.map(dedent).join('\n')
  4341. }
  4342. })()
  4343. //#endregion
  4344.  
  4345. /**
  4346. * Configures – or re-configures – the separated tweets timeline title.
  4347. *
  4348. * If we're currently on the separated tweets timeline and…
  4349. * - …its title has changed, the page title will be changed to "navigate" to it.
  4350. * - …the separated tweets timeline is no longer needed, we'll change the page
  4351. * title to "navigate" back to the Home timeline.
  4352. *
  4353. * @returns {boolean} `true` if "navigation" was triggered by this call
  4354. */
  4355. function configureSeparatedTweetsTimelineTitle() {
  4356. let wasOnSeparatedTweetsTimeline = isOnSeparatedTweetsTimeline()
  4357. let previousTitle = separatedTweetsTimelineTitle
  4358.  
  4359. if (config.retweets == 'separate' && config.quoteTweets == 'separate') {
  4360. separatedTweetsTimelineTitle = getString(config.replaceLogo ? 'SHARED_TWEETS' : 'SHARED')
  4361. } else if (config.retweets == 'separate') {
  4362. separatedTweetsTimelineTitle = getString(config.replaceLogo ? 'RETWEETS' : 'REPOSTS')
  4363. } else if (config.quoteTweets == 'separate') {
  4364. separatedTweetsTimelineTitle = getString(config.replaceLogo ? 'QUOTE_TWEETS' : 'QUOTES')
  4365. } else {
  4366. separatedTweetsTimelineTitle = null
  4367. }
  4368.  
  4369. let titleChanged = previousTitle != separatedTweetsTimelineTitle
  4370. if (wasOnSeparatedTweetsTimeline) {
  4371. if (separatedTweetsTimelineTitle == null) {
  4372. log('moving from separated tweets timeline to Home timeline after config change')
  4373. setTitle(getString('HOME'))
  4374. return true
  4375. }
  4376. if (titleChanged) {
  4377. log('applying new separated tweets timeline title after config change')
  4378. setTitle(separatedTweetsTimelineTitle)
  4379. return true
  4380. }
  4381. } else {
  4382. if (titleChanged && previousTitle != null && lastHomeTimelineTitle == previousTitle) {
  4383. log('updating lastHomeTimelineTitle with new separated tweets timeline title')
  4384. lastHomeTimelineTitle = separatedTweetsTimelineTitle
  4385. }
  4386. }
  4387. }
  4388.  
  4389. const configureThemeCss = (() => {
  4390. let $style
  4391.  
  4392. return function configureThemeCss() {
  4393. $style ??= addStyle('theme')
  4394. let cssRules = []
  4395.  
  4396. if (themeColor != null) {
  4397. cssRules.push(`
  4398. body {
  4399. --theme-color: ${themeColor};
  4400. }
  4401. `)
  4402. }
  4403.  
  4404. if (debug) {
  4405. cssRules.push(`
  4406. [data-item-type]::after {
  4407. position: absolute;
  4408. top: 0;
  4409. ${ltr ? 'right': 'left'}: 50px;
  4410. content: attr(data-item-type);
  4411. font-family: ${fontFamilyRule?.style.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial'};
  4412. background-color: rgb(242, 29, 29);
  4413. color: white;
  4414. font-size: 11px;
  4415. font-weight: bold;
  4416. padding: 4px 6px;
  4417. border-bottom-left-radius: 1em;
  4418. border-bottom-right-radius: 1em;
  4419. }
  4420. `)
  4421. }
  4422.  
  4423. // Active tab colour for custom tabs
  4424. if (themeColor != null && shouldShowSeparatedTweetsTab()) {
  4425. cssRules.push(`
  4426. body.SeparatedTweets #tnt_separated_tweets_tab > a > div > div > div {
  4427. background-color: ${themeColor} !important;
  4428. }
  4429. `)
  4430. }
  4431.  
  4432. if (config.replaceLogo) {
  4433. cssRules.push(`
  4434. ${Selectors.X_LOGO_PATH}, ${Selectors.X_DARUMA_LOGO_PATH} {
  4435. fill: ${THEME_BLUE};
  4436. d: path("${Svgs.TWITTER_LOGO_PATH}");
  4437. }
  4438. .tnt_logo {
  4439. fill: ${THEME_BLUE};
  4440. }
  4441. svg path[d="${Svgs.X_HOME_ACTIVE_PATH}"] {
  4442. d: path("${Svgs.TWITTER_HOME_ACTIVE_PATH}");
  4443. }
  4444. svg path[d="${Svgs.X_HOME_INACTIVE_PATH}"] {
  4445. d: path("${Svgs.TWITTER_HOME_INACTIVE_PATH}");
  4446. }
  4447. `)
  4448. if (desktop) {
  4449. // Revert the Tweet buttons being made monochrome
  4450. cssRules.push(`
  4451. [data-testid="SideNav_NewTweet_Button"],
  4452. [data-testid="tweetButtonInline"],
  4453. [data-testid="tweetButton"] {
  4454. background-color: ${themeColor} !important;
  4455. }
  4456. [data-testid="SideNav_NewTweet_Button"]:hover,
  4457. [data-testid="tweetButtonInline"]:hover:not(:disabled),
  4458. [data-testid="tweetButton"]:hover:not(:disabled) {
  4459. background-color: ${themeColor.replace(')', ', 80%)')} !important;
  4460. }
  4461. body:is(.Dim, .LightsOut):not(.HighContrast) [data-testid="SideNav_NewTweet_Button"] > div,
  4462. body:is(.Dim, .LightsOut):not(.HighContrast) [data-testid="tweetButtonInline"] > div,
  4463. body:is(.Dim, .LightsOut):not(.HighContrast) [data-testid="tweetButton"] > div,
  4464. body:is(.Dim, .LightsOut):not(.HighContrast) [data-testid="SideNav_NewTweet_Button"] > div > svg {
  4465. color: rgb(255, 255, 255) !important;
  4466. }
  4467. `)
  4468. }
  4469. }
  4470.  
  4471. if (config.uninvertFollowButtons) {
  4472. // Shared styles for Following and Follow buttons
  4473. cssRules.push(`
  4474. [role="button"][data-testid$="-unfollow"]:not(:hover) {
  4475. border-color: rgba(0, 0, 0, 0) !important;
  4476. }
  4477. [role="button"][data-testid$="-follow"] {
  4478. background-color: rgba(0, 0, 0, 0) !important;
  4479. }
  4480. `)
  4481. if (config.followButtonStyle == 'monochrome' || themeColor == null) {
  4482. cssRules.push(`
  4483. /* Following button */
  4484. body.Default [role="button"][data-testid$="-unfollow"]:not(:hover) {
  4485. background-color: rgb(15, 20, 25) !important;
  4486. }
  4487. body.Default [role="button"][data-testid$="-unfollow"]:not(:hover) > :is(div, span) {
  4488. color: rgb(255, 255, 255) !important;
  4489. }
  4490. body:is(.Dim, .LightsOut) [role="button"][data-testid$="-unfollow"]:not(:hover) {
  4491. background-color: rgb(255, 255, 255) !important;
  4492. }
  4493. body:is(.Dim, .LightsOut) [role="button"][data-testid$="-unfollow"]:not(:hover) > :is(div, span) {
  4494. color: rgb(15, 20, 25) !important;
  4495. }
  4496. /* Follow button */
  4497. body.Default [role="button"][data-testid$="-follow"] {
  4498. border-color: rgb(207, 217, 222) !important;
  4499. }
  4500. body:is(.Dim, .LightsOut) [role="button"][data-testid$="-follow"] {
  4501. border-color: rgb(83, 100, 113) !important;
  4502. }
  4503. body.Default [role="button"][data-testid$="-follow"] > :is(div, span) {
  4504. color: rgb(15, 20, 25) !important;
  4505. }
  4506. body:is(.Dim, .LightsOut) [role="button"][data-testid$="-follow"] > :is(div, span) {
  4507. color: rgb(255, 255, 255) !important;
  4508. }
  4509. body.Default [role="button"][data-testid$="-follow"]:hover {
  4510. background-color: rgba(15, 20, 25, 0.1) !important;
  4511. }
  4512. body:is(.Dim, .LightsOut) [role="button"][data-testid$="-follow"]:hover {
  4513. background-color: rgba(255, 255, 255, 0.1) !important;
  4514. }
  4515. `)
  4516. }
  4517. if (config.followButtonStyle == 'themed' && themeColor != null) {
  4518. cssRules.push(`
  4519. /* Following button */
  4520. [role="button"][data-testid$="-unfollow"]:not(:hover) {
  4521. background-color: var(--theme-color) !important;
  4522. }
  4523. [role="button"][data-testid$="-unfollow"]:not(:hover) > :is(div, span) {
  4524. color: rgb(255, 255, 255) !important;
  4525. }
  4526. /* Follow button */
  4527. [role="button"][data-testid$="-follow"] {
  4528. border-color: var(--theme-color) !important;
  4529. }
  4530. [role="button"][data-testid$="-follow"] > :is(div, span) {
  4531. color: var(--theme-color) !important;
  4532. }
  4533. [role="button"][data-testid$="-follow"]:hover {
  4534. background-color: var(--theme-color) !important;
  4535. }
  4536. [role="button"][data-testid$="-follow"]:hover > :is(div, span) {
  4537. color: rgb(255, 255, 255) !important;
  4538. }
  4539. `)
  4540. }
  4541. if (mobile) {
  4542. cssRules.push(`
  4543. body.MediaViewer [role="button"][data-testid$="follow"] {
  4544. border: none !important;
  4545. background: transparent !important;
  4546. }
  4547. body.MediaViewer [role="button"][data-testid$="follow"] > div {
  4548. color: var(--theme-color) !important;
  4549. }
  4550. `)
  4551. }
  4552. }
  4553.  
  4554. $style.textContent = cssRules.map(dedent).join('\n')
  4555. }
  4556. })()
  4557.  
  4558. function getColorScheme() {
  4559. return {
  4560. 'rgb(255, 255, 255)': 'Default',
  4561. 'rgb(21, 32, 43)': 'Dim',
  4562. 'rgb(0, 0, 0)': 'LightsOut',
  4563. }[$body.style.backgroundColor]
  4564. }
  4565.  
  4566. /**
  4567. * @param {HTMLElement} $tweet
  4568. * @param {?{getText?: boolean}} options
  4569. * @returns {import("./types").QuotedTweet}
  4570. */
  4571. function getQuotedTweetDetails($tweet, options = {}) {
  4572. let {getText = false} = options
  4573. let $quotedByLink = /** @type {HTMLAnchorElement} */ ($tweet.querySelector('[data-testid="User-Name"] a'))
  4574. let $quotedTweet = $tweet.querySelector('div[id^="id__"] > div[dir] > span').parentElement.nextElementSibling
  4575. let $userName = $quotedTweet?.querySelector('[data-testid="User-Name"]')
  4576. let quotedBy = $quotedByLink?.pathname?.substring(1)
  4577. let user = $userName?.querySelector('[tabindex="-1"]')?.textContent
  4578. let time = $userName?.querySelector('time')?.dateTime
  4579. if (!getText) return {quotedBy, user, time}
  4580.  
  4581. let $heading = $quotedTweet?.querySelector(':scope > div > div:first-child')
  4582. let $qtText = $heading?.nextElementSibling?.querySelector('[lang]')
  4583. let text = $qtText && Array.from($qtText.childNodes, node => {
  4584. if (node.nodeType == 1) {
  4585. if (node.nodeName == 'IMG') return /** @type {HTMLImageElement} */ (node).alt
  4586. return node.textContent
  4587. }
  4588. return node.nodeValue
  4589. }).join('')
  4590. return {quotedBy, user, time, text}
  4591. }
  4592.  
  4593. /**
  4594. * Attempts to determine the type of a timeline Tweet given the element with
  4595. * data-testid="tweet" on it, falling back to TWEET if it doesn't appear to be
  4596. * one of the particular types we care about.
  4597. * @param {HTMLElement} $tweet
  4598. * @param {?boolean} checkSocialContext
  4599. * @returns {import("./types").TweetType}
  4600. */
  4601. function getTweetType($tweet, checkSocialContext = false) {
  4602. if ($tweet.closest(Selectors.PROMOTED_TWEET_CONTAINER)) {
  4603. return 'PROMOTED_TWEET'
  4604. }
  4605. // Assume social context tweets are Retweets
  4606. if ($tweet.querySelector('[data-testid="socialContext"]')) {
  4607. if (checkSocialContext) {
  4608. let svgPath = $tweet.querySelector('svg path')?.getAttribute('d') ?? ''
  4609. if (svgPath.startsWith('M7 4.5C7 3.12 8.12 2 9.5 2h5C1')) return 'PINNED_TWEET'
  4610. }
  4611. // Quoted tweets from accounts you blocked or muted are displayed as an
  4612. // <article> with "This Tweet is unavailable."
  4613. if ($tweet.querySelector('article')) {
  4614. return 'UNAVAILABLE_RETWEET'
  4615. }
  4616. // Quoted tweets are preceded by visually-hidden "Quote" text
  4617. if ($tweet.querySelector('div[id^="id__"] > div[dir] > span')?.textContent.includes(getString('QUOTE'))) {
  4618. return 'RETWEETED_QUOTE_TWEET'
  4619. }
  4620. return 'RETWEET'
  4621. }
  4622. // Quoted tweets are preceded by visually-hidden "Quote" text
  4623. if ($tweet.querySelector('div[id^="id__"] > div[dir] > span')?.textContent.includes(getString('QUOTE'))) {
  4624. return 'QUOTE_TWEET'
  4625. }
  4626. // Quoted tweets from accounts you blocked or muted are displayed as an
  4627. // <article> with "This Tweet is unavailable."
  4628. if ($tweet.querySelector('article')) {
  4629. return 'UNAVAILABLE_QUOTE_TWEET'
  4630. }
  4631. return 'TWEET'
  4632. }
  4633.  
  4634. // Add 1 every time this gets broken: 6
  4635. function getVerifiedProps($svg) {
  4636. let propsGetter = (props) => props?.children?.props?.children?.[0]?.[0]?.props
  4637. let $parent = $svg.parentElement.parentElement
  4638. // Verified badge button on the profile screen
  4639. if (isOnProfilePage() && $svg.parentElement.getAttribute('role') == 'button') {
  4640. $parent = $svg.closest('span').parentElement
  4641. }
  4642. // Link variant in "user followed/liked/retweeted" notifications
  4643. else if (isOnNotificationsPage() && $parent.getAttribute('role') == 'link') {
  4644. propsGetter = (props) => {
  4645. let linkChildren = props?.children?.props?.children?.[0]
  4646. return linkChildren?.[linkChildren.length - 1]?.props
  4647. }
  4648. }
  4649. if ($parent.wrappedJSObject) {
  4650. $parent = $parent.wrappedJSObject
  4651. }
  4652. let reactPropsKey = Object.keys($parent).find(key => key.startsWith('__reactProps$'))
  4653. let props = propsGetter($parent[reactPropsKey])
  4654. if (!props) {
  4655. warn('React props not found for', $svg)
  4656. }
  4657. else if (!('isBlueVerified' in props)) {
  4658. warn('isBlueVerified not in React props for', $svg, {props})
  4659. }
  4660. return props
  4661. }
  4662.  
  4663. /**
  4664. * @param {HTMLElement} $popup
  4665. * @returns {{tookAction: boolean, onPopupClosed?: () => void}}
  4666. */
  4667. function handlePopup($popup) {
  4668. let result = {tookAction: false, onPopupClosed: null}
  4669.  
  4670. // Automatically close any sheet dialog which contains a Premium link
  4671. if (desktop && config.hideTwitterBlueUpsells &&
  4672. $popup.querySelector('[data-testid="mask"]') &&
  4673. $popup.querySelector('[data-testid="sheetDialog"]') &&
  4674. $popup.querySelector('a[href^="/i/premium"]')) {
  4675. log('hidePremiumUpsells: automatically closing Premium upsell dialog')
  4676. let mask = /** @type {HTMLElement} */ ($popup.querySelector('[data-testid="mask"]'))
  4677. mask.click()
  4678. result.tookAction = true
  4679. return result
  4680. }
  4681.  
  4682. // The Sort replies by menu is hydrated asynchronously
  4683. if (isOnIndividualTweetPage() &&
  4684. config.sortReplies != 'relevant' &&
  4685. !userSortedReplies &&
  4686. $popup.innerHTML.includes(`>${getString('SORT_REPLIES_BY')}<`)) {
  4687. log('sortReplies: Sort replies by menu opened')
  4688. void (async () => {
  4689. let $dropdown = await getElement('[role="menu"] [data-testid="Dropdown"]', {
  4690. name: 'Rendered Sort replies by dropdown'
  4691. })
  4692. let $menuItems = /** @type {NodeListOf<HTMLElement>} */ ($dropdown.querySelectorAll('div[role="menuitem"]'))
  4693. let $selectedSvg = $popup.querySelector('div[role="menuitem"] svg')
  4694. for (let [index, $menuItem] of $menuItems.entries()) {
  4695. let shouldBeSelected = index == {recent: 1, liked: 2}[config.sortReplies]
  4696. log({index, $menuItem, shouldBeSelected})
  4697. if (shouldBeSelected) {
  4698. $menuItem.lastElementChild.append($selectedSvg)
  4699. }
  4700. $menuItem.addEventListener('click', () => {
  4701. userSortedReplies = true
  4702. })
  4703. }
  4704. })()
  4705. result.tookAction = true
  4706. return result
  4707. }
  4708.  
  4709. if (desktop && !isDesktopComposeTweetModalOpen &&
  4710. location.pathname.startsWith(ModalPaths.COMPOSE_TWEET)) {
  4711. log('Compose Tweet modal opened')
  4712. isDesktopComposeTweetModalOpen = true
  4713. $desktopComposeTweetModalPopup = $popup
  4714. observeDesktopComposeTweetModal($popup)
  4715. return {
  4716. tookAction: true,
  4717. onPopupClosed() {
  4718. log('Compose Tweet modal closed')
  4719. isDesktopComposeTweetModalOpen = false
  4720. $desktopComposeTweetModalPopup = null
  4721. disconnectAllModalObservers()
  4722. // The Tweet button will re-render if the modal was opened to edit
  4723. // multiple Tweets on the Home timeline.
  4724. if (config.replaceLogo && isOnHomeTimelinePage()) {
  4725. tweakTweetButton()
  4726. }
  4727. }
  4728. }
  4729. }
  4730.  
  4731. if (desktop && !isDesktopMediaModalOpen &&
  4732. URL_MEDIA_RE.test(location.pathname) &&
  4733. currentPath != location.pathname) {
  4734. log('media modal opened')
  4735. isDesktopMediaModalOpen = true
  4736. observeDesktopModalTimeline($popup)
  4737. return {
  4738. tookAction: true,
  4739. onPopupClosed() {
  4740. log('media modal closed')
  4741. isDesktopMediaModalOpen = false
  4742. disconnectAllModalObservers()
  4743. }
  4744. }
  4745. }
  4746.  
  4747. if (config.replaceLogo) {
  4748. let $retweetDropdownItem = $popup.querySelector('div:is([data-testid="retweetConfirm"], [data-testid="repostConfirm"])')
  4749. if ($retweetDropdownItem) {
  4750. tweakRetweetDropdown($retweetDropdownItem, 'div:is([data-testid="retweetConfirm"], [data-testid="repostConfirm"])', 'RETWEET')
  4751. return {tookAction: true}
  4752. }
  4753.  
  4754. let $unretweetDropdownItem = $popup.querySelector('div:is([data-testid="unretweetConfirm"], [data-testid="unrepostConfirm"])')
  4755. if ($unretweetDropdownItem) {
  4756. tweakRetweetDropdown($unretweetDropdownItem, 'div:is([data-testid="unretweetConfirm"], [data-testid="unrepostConfirm"])', 'UNDO_RETWEET')
  4757. return {tookAction: true}
  4758. }
  4759.  
  4760. let $hoverLabel = $popup.querySelector('span[data-testid="HoverLabel"] > span')
  4761. if ($hoverLabel?.textContent == getString('REPOST')) {
  4762. $hoverLabel.textContent = getString('RETWEET')
  4763. }
  4764. }
  4765.  
  4766. if (isOnListPage()) {
  4767. let $switchSvg = $popup.querySelector(`svg path[d="M3 2h18.61l-3.5 7 3.5 7H5v6H3V2zm2 12h13.38l-2.5-5 2.5-5H5v10z"]`)
  4768. if ($switchSvg) {
  4769. addToggleListRetweetsMenuItem($popup.querySelector(`[role="menuitem"]`))
  4770. return {tookAction: true}
  4771. }
  4772. }
  4773.  
  4774. if (config.mutableQuoteTweets) {
  4775. if (quotedTweet) {
  4776. let $blockMenuItem = /** @type {HTMLElement} */ ($popup.querySelector(Selectors.BLOCK_MENU_ITEM))
  4777. if ($blockMenuItem) {
  4778. addMuteQuotesMenuItems($blockMenuItem)
  4779. result.tookAction = true
  4780. // Clear the quoted tweet when the popup closes
  4781. result.onPopupClosed = () => {
  4782. quotedTweet = null
  4783. }
  4784. } else {
  4785. quotedTweet = null
  4786. }
  4787. }
  4788. }
  4789.  
  4790. if (config.fastBlock) {
  4791. if (blockMenuItemSeen && $popup.querySelector('[data-testid="confirmationSheetConfirm"]')) {
  4792. log('fast blocking')
  4793. ;/** @type {HTMLElement} */ ($popup.querySelector('[data-testid="confirmationSheetConfirm"]')).click()
  4794. result.tookAction = true
  4795. }
  4796. else if ($popup.querySelector(Selectors.BLOCK_MENU_ITEM)) {
  4797. log('preparing for fast blocking')
  4798. blockMenuItemSeen = true
  4799. // Create a nested observer for mobile, as it reuses the popup element
  4800. result.tookAction = !mobile
  4801. } else {
  4802. blockMenuItemSeen = false
  4803. }
  4804. }
  4805.  
  4806. if (config.addAddMutedWordMenuItem) {
  4807. let linkSelector = 'a[href$="/settings"]'
  4808. let $link = /** @type {HTMLElement} */ ($popup.querySelector(linkSelector))
  4809. if ($link) {
  4810. addAddMutedWordMenuItem($link, linkSelector)
  4811. result.tookAction = true
  4812. }
  4813. }
  4814.  
  4815. if (config.twitterBlueChecks != 'ignore') {
  4816. // User typeahead dropdown
  4817. let $typeaheadDropdown = /** @type {HTMLElement} */ ($popup.querySelector('div[id^="typeaheadDropdown"]'))
  4818. if ($typeaheadDropdown) {
  4819. log('typeahead dropdown appeared')
  4820. let observer = observeElement($typeaheadDropdown, () => {
  4821. processBlueChecks($typeaheadDropdown)
  4822. }, 'popup typeahead dropdown')
  4823. return {
  4824. tookAction: true,
  4825. onPopupClosed() {
  4826. log('typeahead dropdown closed')
  4827. observer.disconnect()
  4828. }
  4829. }
  4830. }
  4831. }
  4832.  
  4833. if (config.hideGrokNav || config.twitterBlueChecks != 'ignore') {
  4834. // User hovercard popup
  4835. let $hoverCard = /** @type {HTMLElement} */ ($popup.querySelector('[data-testid="HoverCard"]'))
  4836. if ($hoverCard) {
  4837. result.tookAction = true
  4838. getElement('div[data-testid^="UserAvatar-Container"]', {
  4839. context: $hoverCard,
  4840. name: 'user hovercard contents',
  4841. timeout: 500,
  4842. }).then(($contents) => {
  4843. if (!$contents) return
  4844. if (config.hideGrokNav) {
  4845. // Tag Grok "Profile Summary" button
  4846. let $grokButton = $popup.querySelector('[data-testid="HoverCard"] > div > div > div:last-child:has(> button)')
  4847. if ($grokButton) {
  4848. $grokButton.classList.add('GrokButton')
  4849. }
  4850. }
  4851. if (config.twitterBlueChecks != 'ignore') {
  4852. processBlueChecks($popup)
  4853. }
  4854. })
  4855. }
  4856. }
  4857.  
  4858. // Verified account popup when you press the check button on a profile page
  4859. if (config.twitterBlueChecks == 'replace' && isOnProfilePage()) {
  4860. if (mobile) {
  4861. let $verificationBadge = /** @type {HTMLElement} */ ($popup.querySelector('[data-testid="sheetDialog"] [data-testid="verificationBadge"]'))
  4862. if ($verificationBadge) {
  4863. result.tookAction = true
  4864. let $headerBlueCheck = document.querySelector(`body.Profile ${Selectors.MOBILE_TIMELINE_HEADER} .tnt_blue_check`)
  4865. if ($headerBlueCheck) {
  4866. blueCheck($verificationBadge)
  4867. }
  4868. }
  4869. } else {
  4870. let $hoverCard = /** @type {HTMLElement} */ ($popup.querySelector('[data-testid="HoverCard"]'))
  4871. if ($hoverCard) {
  4872. result.tookAction = true
  4873. getElement(':scope > div > div > div > svg[data-testid="verificationBadge"]', {
  4874. context: $hoverCard,
  4875. name: 'verified account hovercard verification badge',
  4876. timeout: 500,
  4877. }).then(($verificationBadge) => {
  4878. if (!$verificationBadge) return
  4879.  
  4880. let $headerBlueCheck = document.querySelector(`body.Profile ${Selectors.PRIMARY_COLUMN} > div > div:first-of-type h2 .tnt_blue_check`)
  4881. if (!$headerBlueCheck) return
  4882.  
  4883. // Wait for the hovercard to render its contents
  4884. let popupRenderObserver = observeElement($popup, (mutations) => {
  4885. if (!mutations.length) return
  4886. blueCheck($popup.querySelector('svg[data-testid="verificationBadge"]'))
  4887. popupRenderObserver.disconnect()
  4888. }, 'verified popup render', {childList: true, subtree: true})
  4889. })
  4890. }
  4891. }
  4892. }
  4893.  
  4894. return result
  4895. }
  4896.  
  4897. function isBlueVerified($svg) {
  4898. let props = getVerifiedProps($svg)
  4899. return Boolean(props && props.isBlueVerified && !(
  4900. props.verifiedType || (
  4901. props.affiliateBadgeInfo?.userLabelType == 'BusinessLabel' &&
  4902. props.affiliateBadgeInfo?.description == 'X'
  4903. )
  4904. ))
  4905. }
  4906.  
  4907. /**
  4908. * @returns {import("./types").VerifiedType}
  4909. */
  4910. function getVerifiedType($svg) {
  4911. let props = getVerifiedProps($svg)
  4912. if (props) {
  4913. if (props.affiliateBadgeInfo?.userLabelType == 'BusinessLabel' &&
  4914. props.affiliateBadgeInfo?.description == 'X')
  4915. // Ignore Twitter associated checks
  4916. return null
  4917. if (props.verifiedType == 'Business')
  4918. return 'VERIFIED_ORG'
  4919. if (props.isBlueVerified)
  4920. return 'BLUE'
  4921. }
  4922. return null
  4923. }
  4924.  
  4925. /**
  4926. * Checks if a tweet is preceded by an element creating a vertical reply line.
  4927. * @param {HTMLElement} $tweet
  4928. * @returns {boolean}
  4929. */
  4930. function isReplyToPreviousTweet($tweet) {
  4931. let $replyLine = $tweet.firstElementChild?.firstElementChild?.firstElementChild?.firstElementChild?.firstElementChild?.firstElementChild
  4932. if ($replyLine) {
  4933. return getComputedStyle($replyLine).width == '2px'
  4934. }
  4935. }
  4936.  
  4937. /**
  4938. * @returns {{disconnect()}}
  4939. */
  4940. function onPopup($popup) {
  4941. log('popup appeared', $popup, location.pathname)
  4942.  
  4943. // If handlePopup did something, we don't need to observe nested popups
  4944. let {tookAction, onPopupClosed} = handlePopup($popup)
  4945. if (tookAction) {
  4946. return onPopupClosed ? {disconnect: onPopupClosed} : null
  4947. }
  4948.  
  4949. /** @type {HTMLElement} */
  4950. let $nestedPopup
  4951.  
  4952. let nestedObserver = observeElement($popup, (mutations) => {
  4953. mutations.forEach((mutation) => {
  4954. mutation.addedNodes.forEach((/** @type {HTMLElement} */ $el) => {
  4955. log('nested popup appeared', $el)
  4956. $nestedPopup = $el
  4957. ;({onPopupClosed} = handlePopup($el))
  4958. })
  4959. mutation.removedNodes.forEach((/** @type {HTMLElement} */ $el) => {
  4960. if ($el !== $nestedPopup) return
  4961. if (onPopupClosed) {
  4962. log('cleaning up after nested popup removed')
  4963. onPopupClosed()
  4964. }
  4965. })
  4966. })
  4967. }, 'nested popup observer')
  4968.  
  4969. let disconnect = nestedObserver.disconnect.bind(nestedObserver)
  4970. nestedObserver.disconnect = () => {
  4971. if (onPopupClosed) {
  4972. log('cleaning up after nested popup observer disconnected')
  4973. onPopupClosed()
  4974. }
  4975. disconnect()
  4976. }
  4977.  
  4978. return nestedObserver
  4979. }
  4980.  
  4981. /**
  4982. * @param {HTMLElement} $timeline
  4983. * @param {string} page
  4984. * @param {import("./types").TimelineOptions?} options
  4985. */
  4986. function onTimelineChange($timeline, page, options = {}) {
  4987. let startTime = Date.now()
  4988. let {classifyTweets = true, hideHeadings = true, isUserTimeline = false} = options
  4989.  
  4990. let isOnHomeTimeline = isOnHomeTimelinePage()
  4991. let isOnListTimeline = isOnListPage()
  4992. let isOnProfileTimeline = isOnProfilePage()
  4993. let timelineHasSpecificHandling = isOnHomeTimeline || isOnListTimeline || isOnProfileTimeline
  4994.  
  4995. if (config.twitterBlueChecks != 'ignore' && (isUserTimeline || !timelineHasSpecificHandling)) {
  4996. processBlueChecks($timeline)
  4997. }
  4998.  
  4999. if (isSafari && config.replaceLogo && isOnNotificationsPage()) {
  5000. processTwitterLogos($timeline)
  5001. }
  5002.  
  5003. if (isUserTimeline || !classifyTweets) return
  5004.  
  5005. let itemTypes = {}
  5006. let hiddenItemCount = 0
  5007. let hiddenItemTypes = {}
  5008.  
  5009. /** @type {?boolean} */
  5010. let hidPreviousItem = null
  5011. /** @type {{$item: Element, hideItem?: boolean}[]} */
  5012. let changes = []
  5013.  
  5014. for (let $item of $timeline.children) {
  5015. /** @type {?import("./types").TimelineItemType} */
  5016. let itemType = null
  5017. /** @type {?boolean} */
  5018. let hideItem = null
  5019. /** @type {?HTMLElement} */
  5020. let $tweet = $item.querySelector(Selectors.TWEET)
  5021. /** @type {boolean} */
  5022. let isReply = false
  5023. /** @type {boolean} */
  5024. let isBlueTweet = false
  5025.  
  5026. if ($tweet != null) {
  5027. itemType = getTweetType($tweet, isOnProfileTimeline)
  5028. if (timelineHasSpecificHandling) {
  5029. isReply = isReplyToPreviousTweet($tweet)
  5030. if (isReply && hidPreviousItem != null) {
  5031. hideItem = hidPreviousItem
  5032. } else {
  5033. if (isOnHomeTimeline) {
  5034. hideItem = shouldHideHomeTimelineItem(itemType, page)
  5035. if (config.mutableQuoteTweets && !hideItem && itemType == 'QUOTE_TWEET' && config.hideQuotesFrom.length > 0) {
  5036. let $quotedByLink = /** @type {HTMLAnchorElement} */ ($tweet.querySelector('[data-testid="User-Name"] a'))
  5037. let quotedBy = $quotedByLink?.pathname.substring(1)
  5038. if (quotedBy) {
  5039. hideItem = config.hideQuotesFrom.includes(quotedBy)
  5040. } else {
  5041. warn('hideQuotesFrom: unable to get quote tweet user')
  5042. }
  5043. }
  5044. }
  5045. else if (isOnListTimeline) {
  5046. hideItem = shouldHideListTimelineItem(itemType)
  5047. }
  5048. else if (isOnProfileTimeline) {
  5049. hideItem = shouldHideProfileTimelineItem(itemType)
  5050. }
  5051. }
  5052.  
  5053. if (!hideItem && config.hideGrokTweets && $tweet.querySelector('a[href^="/i/grok/share/"]')) {
  5054. hideItem = true
  5055. }
  5056.  
  5057. if (!hideItem && config.mutableQuoteTweets && (itemType == 'QUOTE_TWEET' || itemType == 'RETWEETED_QUOTE_TWEET')) {
  5058. if (config.mutedQuotes.length > 0) {
  5059. let quotedTweet = getQuotedTweetDetails($tweet)
  5060. hideItem = config.mutedQuotes.some(muted => muted.user == quotedTweet.user && muted.time == quotedTweet.time)
  5061. }
  5062. if (!hideItem) {
  5063. addCaretMenuListenerForQuoteTweet($tweet)
  5064. }
  5065. }
  5066.  
  5067. if (config.twitterBlueChecks != 'ignore') {
  5068. for (let $svg of $tweet.querySelectorAll(Selectors.VERIFIED_TICK)) {
  5069. let isBlueCheck = isBlueVerified($svg)
  5070. if (!isBlueCheck) continue
  5071.  
  5072. blueCheck($svg)
  5073.  
  5074. // Don't count a tweet as blue if the check is in a quoted tweet
  5075. let userProfileLink = $svg.closest('a[role="link"]:not([href^="/i/status"])')
  5076. if (!userProfileLink) continue
  5077.  
  5078. isBlueTweet = true
  5079. }
  5080. }
  5081. }
  5082.  
  5083. if (!hideItem && config.restoreLinkHeadlines) {
  5084. restoreLinkHeadline($tweet)
  5085. }
  5086. }
  5087. else if (!timelineHasSpecificHandling) {
  5088. if ($item.querySelector(':scope > div > div > div > article')) {
  5089. itemType = 'UNAVAILABLE'
  5090. }
  5091. }
  5092.  
  5093. if (!timelineHasSpecificHandling) {
  5094. if (itemType != null) {
  5095. hideItem = shouldHideOtherTimelineItem(itemType)
  5096. }
  5097. }
  5098.  
  5099. // Special handling for non-Tweet timeline items
  5100. if (itemType == null) {
  5101. if ($item.querySelector('[data-testid="inlinePrompt"]')) {
  5102. itemType = 'INLINE_PROMPT'
  5103. hideItem = config.hideInlinePrompts || (
  5104. config.hideTwitterBlueUpsells && Boolean($item.querySelector('a[href^="/i/premium"]')) ||
  5105. config.hideMonetizationNav && Boolean($item.querySelector('a[href="/settings/monetization"]'))
  5106. )
  5107. } else if ($item.querySelector(Selectors.TIMELINE_HEADING)) {
  5108. itemType = 'HEADING'
  5109. hideItem = hideHeadings && config.hideWhoToFollowEtc
  5110. }
  5111. }
  5112.  
  5113. if (debug && itemType != null) {
  5114. $item.firstElementChild.setAttribute('data-item-type', `${itemType}${isReply ? ' / REPLY' : ''}${isBlueTweet ? ' / BLUE' : ''}`)
  5115. }
  5116.  
  5117. // Assume a non-identified item following an identified item is related
  5118. if (itemType == null && hidPreviousItem != null) {
  5119. hideItem = hidPreviousItem
  5120. itemType = 'SUBSEQUENT_ITEM'
  5121. }
  5122.  
  5123. if (itemType != null) {
  5124. itemTypes[itemType] ||= 0
  5125. itemTypes[itemType]++
  5126. }
  5127.  
  5128. if (hideItem) {
  5129. hiddenItemCount++
  5130. hiddenItemTypes[itemType] ||= 0
  5131. hiddenItemTypes[itemType]++
  5132. }
  5133.  
  5134. if (hideItem != null && $item.firstElementChild) {
  5135. let hidden = $item.firstElementChild.classList.contains('HiddenTweet')
  5136. if (hidden != hideItem) {
  5137. changes.push({$item, hideItem})
  5138. }
  5139. }
  5140.  
  5141. hidPreviousItem = hideItem
  5142. }
  5143.  
  5144. for (let change of changes) {
  5145. change.$item.firstElementChild.classList.toggle('HiddenTweet', change.hideItem)
  5146. }
  5147.  
  5148. if (debug && config.debugLogTimelineStats) {
  5149. log(
  5150. `processed ${$timeline.children.length} timeline item${s($timeline.children.length)} in ${Date.now() - startTime}ms`,
  5151. itemTypes, `hid ${hiddenItemCount}`, hiddenItemTypes
  5152. )
  5153. }
  5154. }
  5155.  
  5156. /**
  5157. * @param {HTMLElement} $timeline
  5158. * @param {import("./types").IndividualTweetTimelineOptions} options
  5159. */
  5160. function onIndividualTweetTimelineChange($timeline, options) {
  5161. let startTime = Date.now()
  5162.  
  5163. let itemTypes = {}
  5164. let hiddenItemCount = 0
  5165. let hiddenItemTypes = {}
  5166.  
  5167. /** @type {?boolean} */
  5168. let hidPreviousItem = null
  5169. /** @type {boolean} */
  5170. let hideAllSubsequentItems = false
  5171. /** @type {string} */
  5172. let opScreenName = /^\/([a-zA-Z\d_]{1,20})\//.exec(location.pathname)[1].toLowerCase()
  5173. /** @type {string} */
  5174. let userScreenName = getUserScreenName()
  5175. /** @type {{$item: Element, hideItem?: boolean}[]} */
  5176. let changes = []
  5177. /** @type {import("./types").UserInfoObject} */
  5178. let userInfo = getUserInfo()
  5179. /** @type {?HTMLElement} */
  5180. let $focusedTweet
  5181.  
  5182. for (let $item of $timeline.children) {
  5183. /** @type {?import("./types").TimelineItemType} */
  5184. let itemType = null
  5185. /** @type {?boolean} */
  5186. let hideItem = null
  5187. /** @type {?HTMLElement} */
  5188. let $tweet = $item.querySelector(Selectors.TWEET)
  5189. /** @type {boolean} */
  5190. let isFocusedTweet = false
  5191. /** @type {boolean} */
  5192. let isReply = false
  5193. /** @type {import("./types").VerifiedType} */
  5194. let tweetVerifiedType = null
  5195. /** @type {?string} */
  5196. let screenName = null
  5197. /** @type {boolean} */
  5198. let isOp = false
  5199. /** @type {boolean} */
  5200. let isUser = false
  5201.  
  5202. if (hideAllSubsequentItems) {
  5203. hideItem = true
  5204. itemType = 'DISCOVER_MORE_TWEET'
  5205. }
  5206. else if ($tweet != null) {
  5207. isFocusedTweet = $tweet.tabIndex == -1
  5208. isReply = isReplyToPreviousTweet($tweet)
  5209. if (isFocusedTweet) {
  5210. itemType = 'FOCUSED_TWEET'
  5211. hideItem = false
  5212. $focusedTweet = $tweet
  5213. } else {
  5214. itemType = getTweetType($tweet)
  5215. if (isReply && hidPreviousItem != null) {
  5216. hideItem = hidPreviousItem
  5217. } else {
  5218. hideItem = shouldHideIndividualTweetTimelineItem(itemType)
  5219. }
  5220. }
  5221.  
  5222. if (!hideItem && config.hideGrokTweets && $tweet.querySelector('a[href^="/i/grok/share/"]')) {
  5223. hideItem = true
  5224. }
  5225.  
  5226. if (!hideItem && (config.twitterBlueChecks != 'ignore' || config.hideTwitterBlueReplies)) {
  5227. for (let $svg of $tweet.querySelectorAll(Selectors.VERIFIED_TICK)) {
  5228. let verifiedType = getVerifiedType($svg)
  5229. if (!verifiedType) continue
  5230.  
  5231. if (config.twitterBlueChecks != 'ignore' && verifiedType == 'BLUE') {
  5232. blueCheck($svg)
  5233. }
  5234.  
  5235. // Don't count a tweet as verified if the check is in a quoted tweet
  5236. let $userProfileLink = /** @type {HTMLAnchorElement} */ ($svg.closest('a[role="link"]:not([href^="/i/status"])'))
  5237. if (!$userProfileLink) continue
  5238.  
  5239. tweetVerifiedType = verifiedType
  5240. screenName = $userProfileLink.href.split('/').pop()
  5241. isOp = screenName.toLowerCase() == opScreenName
  5242. isUser = screenName == userScreenName
  5243. }
  5244.  
  5245. if (tweetVerifiedType &&
  5246. // Don't hide the focused tweet
  5247. !isFocusedTweet &&
  5248. // Replies to the focused tweet don't have the reply indicator
  5249. !isReply &&
  5250. // Don't hide replies by the OP, as it's their thread
  5251. !isOp &&
  5252. // Don't hide replies by the user if they have Premium
  5253. !isUser) {
  5254. itemType = `${tweetVerifiedType}_REPLY`
  5255. if (!hideItem) {
  5256. let user = userInfo[screenName]
  5257. let shouldHideBasedOnVerifiedType = config.hideTwitterBlueReplies && (
  5258. tweetVerifiedType == 'BLUE' ||
  5259. tweetVerifiedType == 'VERIFIED_ORG' && !config.showBlueReplyVerifiedAccounts
  5260. )
  5261. hideItem = shouldHideBasedOnVerifiedType && (user == null || !(
  5262. user.following && !config.hideBlueReplyFollowing ||
  5263. user.followedBy && !config.hideBlueReplyFollowedBy ||
  5264. config.showBlueReplyFollowersCount && user.followersCount >= Number(config.showBlueReplyFollowersCountAmount)
  5265. ))
  5266. }
  5267. }
  5268. }
  5269.  
  5270. if (!hideItem && config.restoreLinkHeadlines) {
  5271. restoreLinkHeadline($tweet)
  5272. }
  5273. }
  5274. else {
  5275. let $article = $item.querySelector('article')
  5276. if ($article) {
  5277. // Deleted or private, unless…
  5278. itemType = 'UNAVAILABLE'
  5279. let $button = $article.querySelector('[role="button"]')
  5280. if ($button) {
  5281. if ($button.textContent == getString('SHOW')) {
  5282. itemType = 'SHOW_MORE'
  5283. }
  5284. else if ($button.textContent == getString('VIEW')) {
  5285. // "This Tweet is from an account you (blocked|muted)." with a View button
  5286. hideItem = config.hideUnavailableQuoteTweets
  5287. }
  5288. }
  5289. else if ($article.textContent == getString('POST_UNAVAILABLE')) {
  5290. // Likely blocked or muted
  5291. hideItem = config.hideUnavailableQuoteTweets
  5292. }
  5293. } else {
  5294. // We need to identify "Show more replies" so it doesn't get hidden if the
  5295. // item immediately before it was hidden.
  5296. let $button = $item.querySelector('button[role="button"]')
  5297. if ($button) {
  5298. if ($button?.textContent == getString('SHOW_MORE_REPLIES')) {
  5299. itemType = 'SHOW_MORE'
  5300. }
  5301. } else {
  5302. let $heading = $item.querySelector(Selectors.TIMELINE_HEADING)
  5303. if ($heading) {
  5304. // Discover More headings have a description next to them
  5305. if ($heading.nextElementSibling &&
  5306. $heading.nextElementSibling.tagName == 'DIV' &&
  5307. $heading.nextElementSibling.getAttribute('dir') != null) {
  5308. itemType = 'DISCOVER_MORE_HEADING'
  5309. hideItem = config.hideMoreTweets
  5310. hideAllSubsequentItems = config.hideMoreTweets
  5311. } else {
  5312. itemType = 'HEADING'
  5313. }
  5314. }
  5315. }
  5316. }
  5317. }
  5318.  
  5319. if (debug && itemType != null) {
  5320. $item.firstElementChild.setAttribute('data-item-type', `${itemType}${isReply ? ' / REPLY' : ''}${isOp ? ' / OP' : ''}`)
  5321. }
  5322.  
  5323. // Assume a non-identified item following an identified item is related
  5324. if (itemType == null && hidPreviousItem != null) {
  5325. hideItem = hidPreviousItem
  5326. itemType = 'SUBSEQUENT_ITEM'
  5327. }
  5328.  
  5329. if (itemType != null) {
  5330. itemTypes[itemType] ||= 0
  5331. itemTypes[itemType]++
  5332. }
  5333.  
  5334. if (hideItem) {
  5335. hiddenItemCount++
  5336. hiddenItemTypes[itemType] ||= 0
  5337. hiddenItemTypes[itemType]++
  5338. }
  5339.  
  5340. if (isFocusedTweet) {
  5341. // Tweets prior to the focused tweet should never be hidden
  5342. changes = []
  5343. hiddenItemCount = 0
  5344. hiddenItemTypes = {}
  5345. }
  5346. else if (hideItem != null && $item.firstElementChild) {
  5347. let hidden = $item.firstElementChild.classList.contains('HiddenTweet')
  5348. if (hidden != hideItem) {
  5349. changes.push({$item, hideItem})
  5350. }
  5351. }
  5352.  
  5353. hidPreviousItem = hideItem
  5354. }
  5355.  
  5356. for (let change of changes) {
  5357. change.$item.firstElementChild.classList.toggle('HiddenTweet', change.hideItem)
  5358. }
  5359.  
  5360. tweakFocusedTweet($focusedTweet, options)
  5361.  
  5362. if (debug && config.debugLogTimelineStats) {
  5363. log(
  5364. `processed ${$timeline.children.length} thread item${s($timeline.children.length)} in ${Date.now() - startTime}ms`,
  5365. itemTypes, `hid ${hiddenItemCount}`, hiddenItemTypes
  5366. )
  5367. }
  5368. }
  5369.  
  5370. /**
  5371. * Title format (including notification count):
  5372. * - LTR: (3) ${title} / X
  5373. * - RTL: (3) X \ ${title}
  5374. * @param {string} title
  5375. */
  5376. function onTitleChange(title) {
  5377. log('title changed', {title, path: location.pathname})
  5378.  
  5379. if (checkforDisabledHomeTimeline()) return
  5380.  
  5381. // Ignore leading notification counts in titles
  5382. let notificationCount = ''
  5383. if (TITLE_NOTIFICATION_RE.test(title)) {
  5384. notificationCount = TITLE_NOTIFICATION_RE.exec(title)[0]
  5385. title = title.replace(TITLE_NOTIFICATION_RE, '')
  5386. }
  5387.  
  5388. // After we replace the shortcut icon, Twitter stops updating it to add/remove
  5389. // the notifications pip, so we need to manage the pip ourselves.
  5390. if (config.replaceLogo && Boolean(notificationCount) != Boolean(currentNotificationCount)) {
  5391. observeFavicon.forceUpdate(Boolean(notificationCount))
  5392. }
  5393.  
  5394. let homeNavigationWasUsed = homeNavigationIsBeingUsed
  5395. homeNavigationIsBeingUsed = false
  5396.  
  5397. // Ignore Flash of Uninitialised Title when navigating to a page for the first
  5398. // time, except in scenarios where we know an empty title is being set.
  5399. if (title == 'X' || title == getString('TWITTER')) {
  5400. // On mobile, the media viewer sets an empty title
  5401. if (mobile && (URL_MEDIA_RE.test(location.pathname) || URL_MEDIAVIEWER_RE.test(location.pathname))) {
  5402. log('viewing media on mobile')
  5403. }
  5404. // On desktop, the root Settings page sets an empty title when the sidebar
  5405. // is hidden.
  5406. else if (desktop && location.pathname == '/settings' && currentPath != '/settings') {
  5407. log('viewing root Settings page')
  5408. }
  5409. // On desktop, the root Messages page sometimes sets an empty title
  5410. else if (desktop && location.pathname == '/messages' && currentPath != '/messages') {
  5411. log('viewing root Messages page')
  5412. }
  5413. // The Bookmarks page sets an empty title
  5414. else if (location.pathname.startsWith(PagePaths.BOOKMARKS) && !currentPath.startsWith(PagePaths.BOOKMARKS)) {
  5415. log('viewing Bookmarks page')
  5416. }
  5417. else {
  5418. log('ignoring Flash of Uninitialised Title')
  5419. return
  5420. }
  5421. }
  5422.  
  5423. // Remove " / Twitter" or "Twitter \ " from the title
  5424. let newPage = title
  5425. if (newPage != 'X' && newPage != getString('TWITTER')) {
  5426. newPage = title.slice(...ltr ? [0, title.lastIndexOf('/') - 1] : [title.indexOf('\\') + 2])
  5427. }
  5428.  
  5429. let hasDesktopModalBeenOpenedOrClosed = desktop && (
  5430. // Timeline settings dialog opened
  5431. location.pathname == PagePaths.TIMELINE_SETTINGS ||
  5432. // Timeline settings dialog closed
  5433. currentPath == PagePaths.TIMELINE_SETTINGS ||
  5434. // Media modal opened
  5435. URL_MEDIA_RE.test(location.pathname) ||
  5436. // Media modal closed
  5437. URL_MEDIA_RE.test(currentPath) ||
  5438. // "Send via Direct Message" dialog opened
  5439. location.pathname == ModalPaths.COMPOSE_MESSAGE ||
  5440. // "Send via Direct Message" dialog closed
  5441. currentPath == ModalPaths.COMPOSE_MESSAGE ||
  5442. // Compose Tweet dialog opened
  5443. location.pathname == ModalPaths.COMPOSE_TWEET ||
  5444. // Compose Tweet dialog closed
  5445. currentPath == ModalPaths.COMPOSE_TWEET
  5446. )
  5447.  
  5448. if (newPage == currentPage) {
  5449. log(`ignoring duplicate title change`)
  5450. // Navigation within the Compose Tweet modal triggers duplcate title changes
  5451. if (isDesktopComposeTweetModalOpen) {
  5452. if (currentPath == ModalPaths.COMPOSE_TWEET && COMPOSE_TWEET_MODAL_PAGES.has(location.pathname)) {
  5453. log('navigated away from Compose Tweet editor')
  5454. disconnectAllModalObservers()
  5455. }
  5456. else if (COMPOSE_TWEET_MODAL_PAGES.has(currentPath) && location.pathname == ModalPaths.COMPOSE_TWEET) {
  5457. log('navigated back to Compose Tweet editor')
  5458. observeDesktopComposeTweetModal($desktopComposeTweetModalPopup)
  5459. }
  5460. }
  5461. currentNotificationCount = notificationCount
  5462. currentPath = location.pathname
  5463. return
  5464. }
  5465.  
  5466. // Search terms are shown in the title
  5467. if (currentPath == PagePaths.SEARCH && location.pathname == PagePaths.SEARCH) {
  5468. log('ignoring title change on Search page')
  5469. currentNotificationCount = notificationCount
  5470. return
  5471. }
  5472.  
  5473. // On desktop, stay on the separated tweets timeline when…
  5474. if (desktop && currentPage == separatedTweetsTimelineTitle &&
  5475. // …the title has changed back to the Home timeline…
  5476. (newPage == getString('HOME')) &&
  5477. // …the Home nav link or Following / Home header _wasn't_ clicked and…
  5478. !homeNavigationWasUsed &&
  5479. (
  5480. // …a modal which changes the pathname has been opened or closed.
  5481. hasDesktopModalBeenOpenedOrClosed ||
  5482. // …the notification count in the title changed.
  5483. notificationCount != currentNotificationCount
  5484. )) {
  5485. log('ignoring title change on separated tweets timeline')
  5486. currentNotificationCount = notificationCount
  5487. currentPath = location.pathname
  5488. setTitle(separatedTweetsTimelineTitle)
  5489. return
  5490. }
  5491.  
  5492. // Restore display of the separated tweets timelne if it's the last one we
  5493. // saw, and the user navigated back home without using the Home navigation
  5494. // item.
  5495. if (location.pathname == PagePaths.HOME &&
  5496. currentPath != PagePaths.HOME &&
  5497. !homeNavigationWasUsed &&
  5498. lastHomeTimelineTitle != null &&
  5499. separatedTweetsTimelineTitle != null &&
  5500. lastHomeTimelineTitle == separatedTweetsTimelineTitle) {
  5501. log('restoring display of the separated tweets timeline')
  5502. currentNotificationCount = notificationCount
  5503. currentPath = location.pathname
  5504. setTitle(separatedTweetsTimelineTitle)
  5505. return
  5506. }
  5507.  
  5508. // Assumption: all non-FOUT, non-duplicate title changes are navigation, which
  5509. // need the page to be re-processed.
  5510.  
  5511. currentPage = newPage
  5512. currentNotificationCount = notificationCount
  5513. currentPath = location.pathname
  5514.  
  5515. if (isOnHomeTimelinePage()) {
  5516. lastHomeTimelineTitle = currentPage
  5517. }
  5518.  
  5519. log('processing new page')
  5520.  
  5521. processCurrentPage()
  5522. }
  5523.  
  5524. /**
  5525. * Processes all Twitter Blue checks inside an element.
  5526. * @param {HTMLElement} $el
  5527. */
  5528. function processBlueChecks($el) {
  5529. for (let $svg of $el.querySelectorAll(`${Selectors.VERIFIED_TICK}:not(.tnt_blue_check)`)) {
  5530. if (isBlueVerified($svg)) {
  5531. blueCheck($svg)
  5532. }
  5533. }
  5534. }
  5535.  
  5536. /**
  5537. * Processes all Twitter logos inside an element.
  5538. */
  5539. function processTwitterLogos($el) {
  5540. for (let $svgPath of $el.querySelectorAll(Selectors.X_LOGO_PATH)) {
  5541. twitterLogo($svgPath)
  5542. }
  5543. }
  5544.  
  5545. function processCurrentPage() {
  5546. if (pageObservers.length > 0) {
  5547. log(
  5548. `disconnecting ${pageObservers.length} page observer${s(pageObservers.length)}`,
  5549. pageObservers.map(observer => observer['name'])
  5550. )
  5551. pageObservers.forEach(observer => observer.disconnect())
  5552. pageObservers = []
  5553. }
  5554.  
  5555. // Hooks for styling pages
  5556. $body.classList.toggle('Bookmarks', isOnBookmarksPage())
  5557. $body.classList.toggle('Community', isOnCommunityPage())
  5558. $body.classList.toggle('Communities', isOnCommunitiesPage())
  5559. $body.classList.toggle('Explore', isOnExplorePage())
  5560. $body.classList.toggle('HideSidebar', shouldHideSidebar())
  5561. $body.classList.toggle('List', isOnListPage())
  5562. $body.classList.toggle('HomeTimeline', isOnHomeTimelinePage())
  5563. $body.classList.toggle('Notifications', isOnNotificationsPage())
  5564. $body.classList.toggle('Profile', isOnProfilePage())
  5565. if (!isOnProfilePage()) {
  5566. $body.classList.remove('OwnProfile', 'PremiumProfile')
  5567. }
  5568. $body.classList.toggle('ProfileFollows', isOnFollowListPage())
  5569. if (!isOnFollowListPage()) {
  5570. $body.classList.remove('Subscriptions')
  5571. }
  5572. $body.classList.toggle('QuoteTweets', isOnQuoteTweetsPage())
  5573. $body.classList.toggle('Tweet', isOnIndividualTweetPage())
  5574. $body.classList.toggle('Search', isOnSearchPage())
  5575. $body.classList.toggle('Settings', isOnSettingsPage())
  5576. $body.classList.toggle('MobileMedia', mobile && URL_MEDIA_RE.test(location.pathname))
  5577. $body.classList.toggle('MediaViewer', mobile && URL_MEDIAVIEWER_RE.test(location.pathname))
  5578. $body.classList.remove('SeparatedTweets')
  5579.  
  5580. if (desktop) {
  5581. let shouldObserveSidebarForConfig = (
  5582. config.twitterBlueChecks != 'ignore' ||
  5583. config.fullWidthContent ||
  5584. config.hideExploreNav && config.hideExploreNavWithSidebar
  5585. )
  5586. if (shouldObserveSidebarForConfig && !isOnMessagesPage() && !isOnSettingsPage()) {
  5587. observeSidebar()
  5588. } else {
  5589. $body.classList.remove('Sidebar')
  5590. }
  5591. if (isSafari && config.replaceLogo) {
  5592. tweakDesktopLogo()
  5593. }
  5594. }
  5595.  
  5596. if (isSafari && config.replaceLogo) {
  5597. tweakHomeIcon()
  5598. }
  5599.  
  5600. if (config.twitterBlueChecks != 'ignore' && (isOnSearchPage() || isOnExplorePage())) {
  5601. observeSearchForm()
  5602. }
  5603.  
  5604. if (isOnHomeTimelinePage()) {
  5605. tweakHomeTimelinePage()
  5606. }
  5607. else {
  5608. removeMobileTimelineHeaderElements()
  5609. }
  5610.  
  5611. if (isOnProfilePage()) {
  5612. tweakProfilePage()
  5613. }
  5614. else if (isOnFollowListPage()) {
  5615. tweakFollowListPage()
  5616. }
  5617. else if (isOnIndividualTweetPage()) {
  5618. tweakIndividualTweetPage()
  5619. }
  5620. else if (isOnNotificationsPage()) {
  5621. tweakNotificationsPage()
  5622. }
  5623. else if (isOnSearchPage()) {
  5624. tweakSearchPage()
  5625. }
  5626. else if (URL_TWEET_ENGAGEMENT_RE.test(currentPath)) {
  5627. tweakTweetEngagementPage()
  5628. }
  5629. else if (isOnListPage()) {
  5630. tweakListPage()
  5631. }
  5632. else if (isOnListsPage()) {
  5633. tweakListsPage()
  5634. }
  5635. else if (isOnExplorePage()) {
  5636. tweakExplorePage()
  5637. }
  5638. else if (isOnBookmarksPage()) {
  5639. tweakBookmarksPage()
  5640. }
  5641. else if (isOnCommunitiesPage()) {
  5642. tweakCommunitiesPage()
  5643. }
  5644. else if (isOnCommunityPage()) {
  5645. tweakCommunityPage()
  5646. }
  5647. else if (isOnCommunityMembersPage()) {
  5648. tweakCommunityMembersPage()
  5649. }
  5650. else if (isOnDisplaySettingsPage() || isOnAccessibilitySettingsPage()) {
  5651. tweakDisplaySettingsPage()
  5652. }
  5653.  
  5654. // On mobile, these are pages instead of modals
  5655. if (mobile) {
  5656. if (currentPath == PagePaths.COMPOSE_TWEET) {
  5657. tweakMobileComposeTweetPage()
  5658. }
  5659. else if (URL_MEDIAVIEWER_RE.test(currentPath)) {
  5660. tweakMobileMediaViewerPage()
  5661. }
  5662. }
  5663. }
  5664.  
  5665. /**
  5666. * @returns {boolean} `true` if this call replaces the current location
  5667. */
  5668. function redirectToTwitter() {
  5669. if (config.redirectToTwitter &&
  5670. location.hostname.endsWith('x.com') &&
  5671. // Don't redirect the path used by the OldTweetDeck extension
  5672. location.pathname != '/i/tweetdeck') {
  5673. // If we got a logout redirect from twitter.com, redirect back to the login page
  5674. let pathname = location.search.includes('logout=') ? '/i/flow/login' : location.pathname || '/home'
  5675. let redirectUrl = `https://twitter.com${pathname}?mx=1`
  5676. log('redirectToTwitter: redirecting from', location.href, 'to', redirectUrl)
  5677. location.replace(redirectUrl)
  5678. return true
  5679. }
  5680. return false
  5681. }
  5682.  
  5683. /**
  5684. * The mobile version of Twitter reuses heading elements between screens, so we
  5685. * always remove any elements which could be there from the previous page and
  5686. * re-add them later when needed.
  5687. */
  5688. function removeMobileTimelineHeaderElements() {
  5689. if (mobile) {
  5690. document.querySelector('#tnt_separated_tweets_tab')?.remove()
  5691. }
  5692. }
  5693.  
  5694. /**
  5695. * @param {HTMLElement} $tweet
  5696. */
  5697. function restoreLinkHeadline($tweet) {
  5698. let $link = /** @type {HTMLElement} */ ($tweet.querySelector('div[data-testid="card.layoutLarge.media"] > a[rel][aria-label]'))
  5699. if ($link && !$link.dataset.headlineRestored) {
  5700. let [site, ...rest] = $link.getAttribute('aria-label').split(' ')
  5701. let headline = rest.join(' ')
  5702. $link.lastElementChild?.classList.add('tnt_overlay_headline')
  5703. $link.insertAdjacentHTML('beforeend', `<div class="tnt_link_headline ${fontFamilyRule?.selectorText?.replace('.', '') || 'tnt_font_family'}" style="border-top: 1px solid var(--border-color); padding: 14px;">
  5704. <div style="color: var(--color); margin-bottom: 2px;">${site}</div>
  5705. <div style="color: var(--color-emphasis)">${headline}</div>
  5706. </div>`)
  5707. $link.dataset.headlineRestored = 'true'
  5708. }
  5709. }
  5710.  
  5711. /**
  5712. * @param {HTMLElement} $focusedTweet
  5713. */
  5714. function restoreTweetInteractionsLinks($focusedTweet) {
  5715. if (!config.restoreQuoteTweetsLink && !config.restoreOtherInteractionLinks) return
  5716.  
  5717. let [tweetLink, tweetId] = location.pathname.match(/^\/[a-zA-Z\d_]{1,20}\/status\/(\d+)/) ?? []
  5718. let tweetInfo = getTweetInfo(tweetId)
  5719. log('focused tweet', {tweetLink, tweetId, tweetInfo})
  5720. if (!tweetInfo) return
  5721.  
  5722. let isOwnTweet = Boolean($focusedTweet.querySelector('a[data-testid="analyticsButton"]'))
  5723. let shouldDisplayLinks = (
  5724. (config.restoreQuoteTweetsLink && tweetInfo.quote_count > 0) ||
  5725. (config.restoreOtherInteractionLinks && (tweetInfo.retweet_count > 0 || isOwnTweet && tweetInfo.favorite_count > 0))
  5726. )
  5727. let $existingLinks = $focusedTweet.querySelector('#tntInteractionLinks')
  5728. if (!shouldDisplayLinks || $existingLinks) {
  5729. if (!shouldDisplayLinks) $existingLinks?.remove()
  5730. return
  5731. }
  5732.  
  5733. let $group = $focusedTweet.querySelector('[role="group"][id^="id__"]')
  5734. if (!$group) return warn('focused tweet action bar not found')
  5735.  
  5736. $group.parentElement.insertAdjacentHTML('beforebegin', `
  5737. <div id="tntInteractionLinks">
  5738. <div class="${fontFamilyRule?.selectorText?.replace('.', '') || 'tnt_font_family'}" style="padding: 16px 4px; border-top: 1px solid var(--border-color); display: flex; gap: 20px;">
  5739. ${tweetInfo.quote_count > 0 ? `<a id="tntQuoteTweetsLink" class="quoteTweets" href="${tweetLink}/quotes" dir="auto" role="link">
  5740. <span id="tntQuoteTweetCount">
  5741. ${Intl.NumberFormat(lang, {notation: tweetInfo.quote_count < 10000 ? 'standard' : 'compact', compactDisplay: 'short'}).format(tweetInfo.quote_count)}
  5742. </span>
  5743. <span>${getString(tweetInfo.quote_count == 1 ? (config.replaceLogo ? 'QUOTE_TWEET' : 'QUOTE') : (config.replaceLogo ? 'QUOTE_TWEETS' : 'QUOTES'))}</span>
  5744. </a>` : ''}
  5745. ${tweetInfo.retweet_count > 0 ? `<a id="tntRetweetsLink" data-tab="2" href="${tweetLink}/retweets" dir="auto" role="link">
  5746. <span id="tntRetweetCount">
  5747. ${Intl.NumberFormat(lang, {notation: tweetInfo.retweet_count < 10000 ? 'standard' : 'compact', compactDisplay: 'short'}).format(tweetInfo.retweet_count)}
  5748. </span>
  5749. <span>${getString(config.replaceLogo ? 'RETWEETS' : 'REPOSTS')}</span>
  5750. </a>` : ''}
  5751. ${isOwnTweet && tweetInfo.favorite_count > 0 ? `<a id="tntLikesLink" data-tab="3" href="${tweetLink}/likes" dir="auto" role="link">
  5752. <span id="tntLikeCount">
  5753. ${Intl.NumberFormat(lang, {notation: tweetInfo.favorite_count < 10000 ? 'standard' : 'compact', compactDisplay: 'short'}).format(tweetInfo.favorite_count)}
  5754. </span>
  5755. <span>${getString('LIKES')}</span>
  5756. </a>` : ''}
  5757. </div>
  5758. </div>
  5759. `)
  5760.  
  5761. let links = /** @type {NodeListOf<HTMLAnchorElement>} */ ($focusedTweet.querySelectorAll('#tntInteractionLinks a'))
  5762. links.forEach(($link) => {
  5763. $link.addEventListener('click', async (e) => {
  5764. let $caret = /** @type {HTMLElement} */ ($focusedTweet.querySelector('[data-testid="caret"]'))
  5765. if (!$caret) return warn('focused tweet menu caret not found')
  5766.  
  5767. log('clicking "View post engagements" menu item')
  5768. e.preventDefault()
  5769. $caret.click()
  5770. let $tweetEngagements = await getElement('#layers a[data-testid="tweetEngagements"]', {
  5771. name: 'View post engagements menu item',
  5772. stopIf: pageIsNot(currentPage),
  5773. timeout: 500,
  5774. })
  5775. if ($tweetEngagements) {
  5776. tweetInteractionsTab = $link.dataset.tab || null
  5777. $tweetEngagements.click()
  5778. } else {
  5779. warn('falling back to full page refresh')
  5780. location.href = $link.href
  5781. }
  5782. })
  5783. })
  5784. }
  5785.  
  5786. /**
  5787. * Sets the page name in <title>, retaining any current notification count.
  5788. * @param {string} page
  5789. */
  5790. function setTitle(page) {
  5791. let name = config.replaceLogo ? getString('TWITTER') : 'X'
  5792. let notificationCount = config.hideNotifications != 'ignore' ? (
  5793. ''
  5794. ) : (
  5795. hiddenNotificationCount || currentNotificationCount
  5796. )
  5797. document.title = ltr ? (
  5798. `${notificationCount}${page} / ${name}`
  5799. ) : (
  5800. `${notificationCount}${name} \\ ${page}`
  5801. )
  5802. }
  5803.  
  5804. /**
  5805. * @param {import("./types").TimelineItemType} type
  5806. * @returns {boolean}
  5807. */
  5808. function shouldHideIndividualTweetTimelineItem(type) {
  5809. switch (type) {
  5810. case 'QUOTE_TWEET':
  5811. case 'RETWEET':
  5812. case 'RETWEETED_QUOTE_TWEET':
  5813. case 'TWEET':
  5814. return false
  5815. case 'UNAVAILABLE_QUOTE_TWEET':
  5816. case 'UNAVAILABLE_RETWEET':
  5817. return config.hideUnavailableQuoteTweets
  5818. default:
  5819. return true
  5820. }
  5821. }
  5822.  
  5823. /**
  5824. * @param {import("./types").TimelineItemType} type
  5825. * @returns {boolean}
  5826. */
  5827. function shouldHideListTimelineItem(type) {
  5828. switch (type) {
  5829. case 'RETWEET':
  5830. case 'RETWEETED_QUOTE_TWEET':
  5831. return config.listRetweets == 'hide'
  5832. case 'UNAVAILABLE_QUOTE_TWEET':
  5833. return config.hideUnavailableQuoteTweets
  5834. case 'UNAVAILABLE_RETWEET':
  5835. return config.hideUnavailableQuoteTweets || config.listRetweets == 'hide'
  5836. default:
  5837. return false
  5838. }
  5839. }
  5840.  
  5841. /**
  5842. * @param {import("./types").TimelineItemType} type
  5843. * @param {string} page
  5844. * @returns {boolean}
  5845. */
  5846. function shouldHideHomeTimelineItem(type, page) {
  5847. switch (type) {
  5848. case 'QUOTE_TWEET':
  5849. return shouldHideSharedTweet(config.quoteTweets, page)
  5850. case 'RETWEET':
  5851. return selectedHomeTabIndex >= 2 ? config.listRetweets == 'hide' : shouldHideSharedTweet(config.retweets, page)
  5852. case 'RETWEETED_QUOTE_TWEET':
  5853. return selectedHomeTabIndex >= 2 ? (
  5854. config.listRetweets == 'hide'
  5855. ) : (
  5856. shouldHideSharedTweet(config.retweets, page) || shouldHideSharedTweet(config.quoteTweets, page)
  5857. )
  5858. case 'TWEET':
  5859. return page == separatedTweetsTimelineTitle
  5860. case 'UNAVAILABLE_QUOTE_TWEET':
  5861. return config.hideUnavailableQuoteTweets || shouldHideSharedTweet(config.quoteTweets, page)
  5862. case 'UNAVAILABLE_RETWEET':
  5863. return config.hideUnavailableQuoteTweets || selectedHomeTabIndex >= 2 ? config.listRetweets == 'hide' : shouldHideSharedTweet(config.retweets, page)
  5864. default:
  5865. return true
  5866. }
  5867. }
  5868.  
  5869. /**
  5870. * @param {import("./types").TimelineItemType} type
  5871. * @returns {boolean}
  5872. */
  5873. function shouldHideProfileTimelineItem(type) {
  5874. switch (type) {
  5875. case 'PINNED_TWEET':
  5876. case 'QUOTE_TWEET':
  5877. case 'TWEET':
  5878. return false
  5879. case 'RETWEET':
  5880. case 'RETWEETED_QUOTE_TWEET':
  5881. return config.hideProfileRetweets
  5882. case 'UNAVAILABLE_QUOTE_TWEET':
  5883. return config.hideUnavailableQuoteTweets
  5884. default:
  5885. return true
  5886. }
  5887. }
  5888.  
  5889. /**
  5890. * @param {import("./types").TimelineItemType} type
  5891. * @returns {boolean}
  5892. */
  5893. function shouldHideOtherTimelineItem(type) {
  5894. switch (type) {
  5895. case 'QUOTE_TWEET':
  5896. case 'RETWEET':
  5897. case 'RETWEETED_QUOTE_TWEET':
  5898. case 'TWEET':
  5899. case 'UNAVAILABLE':
  5900. case 'UNAVAILABLE_QUOTE_TWEET':
  5901. case 'UNAVAILABLE_RETWEET':
  5902. return false
  5903. default:
  5904. return true
  5905. }
  5906. }
  5907.  
  5908. /**
  5909. * @param {import("./types").SharedTweetsConfig} config
  5910. * @param {string} page
  5911. * @returns {boolean}
  5912. */
  5913. function shouldHideSharedTweet(config, page) {
  5914. switch (config) {
  5915. case 'hide': return true
  5916. case 'ignore': return page == separatedTweetsTimelineTitle
  5917. case 'separate': return page != separatedTweetsTimelineTitle
  5918. }
  5919. }
  5920.  
  5921. async function tweakBookmarksPage() {
  5922. if (config.twitterBlueChecks != 'ignore' || config.restoreLinkHeadlines) {
  5923. observeTimeline(currentPage)
  5924. }
  5925. }
  5926.  
  5927. async function tweakExplorePage() {
  5928. if (!config.hideExplorePageContents) {
  5929. if (config.twitterBlueChecks != 'ignore') {
  5930. observeTimeline(currentPage, {
  5931. classifyTweets: false,
  5932. isTabbed: true,
  5933. tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child > div',
  5934. })
  5935. }
  5936. return
  5937. }
  5938.  
  5939. let $searchInput = await getElement('input[data-testid="SearchBox_Search_Input"]', {
  5940. name: 'explore page search input',
  5941. stopIf: () => !isOnExplorePage(),
  5942. })
  5943. if (!$searchInput) return
  5944.  
  5945. log('focusing search input')
  5946. $searchInput.focus()
  5947.  
  5948. if (mobile) {
  5949. // The back button appears after the search input is focused on mobile. When
  5950. // you tap it or otherwise navigate back, it's replaced with the slide-out
  5951. // menu button and Explore page contents are shown - we want to skip that.
  5952. let $backButton = await getElement('div[data-testid="app-bar-back"]', {
  5953. name: 'back button',
  5954. stopIf: () => !isOnExplorePage(),
  5955. })
  5956. if (!$backButton) return
  5957.  
  5958. pageObservers.push(
  5959. observeElement($backButton.parentElement, (mutations) => {
  5960. mutations.forEach((mutation) => {
  5961. mutation.addedNodes.forEach((/** @type {HTMLElement} */ $el) => {
  5962. if ($el.querySelector('[data-testid="DashButton_ProfileIcon_Link"]')) {
  5963. log('slide-out menu button appeared, going back to skip Explore page')
  5964. history.go(-2)
  5965. }
  5966. })
  5967. })
  5968. }, 'back button parent')
  5969. )
  5970. }
  5971. }
  5972.  
  5973. function tweakCommunitiesPage() {
  5974. observeTimeline(currentPage)
  5975. }
  5976.  
  5977. function tweakCommunityPage() {
  5978. if (config.twitterBlueChecks != 'ignore') {
  5979. observeTimeline(currentPage, {
  5980. classifyTweets: false,
  5981. isTabbed: true,
  5982. tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`,
  5983. onTimelineAppeared() {
  5984. // The About tab has static content at the top which can include a check
  5985. if (/\/about\/?$/.test(location.pathname)) {
  5986. processBlueChecks(document.querySelector(Selectors.PRIMARY_COLUMN))
  5987. }
  5988. }
  5989. })
  5990. }
  5991. }
  5992.  
  5993. function tweakCommunityMembersPage() {
  5994. if (config.twitterBlueChecks != 'ignore') {
  5995. observeTimeline(currentPage, {
  5996. classifyTweets: false,
  5997. isTabbed: true,
  5998. timelineSelector: 'div[data-testid="primaryColumn"] > div > div:last-child',
  5999. })
  6000. }
  6001. }
  6002.  
  6003. function tweakDisplaySettingsPage() {
  6004. (async () => {
  6005. let $colorRerenderBoundary = await getElement('#react-root > div > div')
  6006.  
  6007. pageObservers.push(
  6008. observeElement($colorRerenderBoundary, () => {
  6009. let newThemeColor = getThemeColorFromState()
  6010. if (newThemeColor == themeColor) return
  6011.  
  6012. log('Color setting changed')
  6013. themeColor = newThemeColor
  6014. configureThemeCss()
  6015. observePopups()
  6016. observeSideNavTweetButton()
  6017. }, 'Color change re-render boundary')
  6018. )
  6019. })()
  6020.  
  6021. if (desktop) {
  6022. pageObservers.push(
  6023. observeElement($html, () => {
  6024. if (!$html.style.fontSize) return
  6025.  
  6026. if ($html.style.fontSize != fontSize) {
  6027. fontSize = $html.style.fontSize
  6028. log(`<html> fontSize has changed to ${fontSize}`)
  6029. configureDynamicCss()
  6030. observePopups()
  6031. observeSideNavTweetButton()
  6032. }
  6033. }, '<html> style attribute for font size changes', {
  6034. attributes: true,
  6035. attributeFilter: ['style']
  6036. })
  6037. )
  6038. }
  6039. }
  6040.  
  6041. const tweakFocusedTweet = (() => {
  6042. let waitingForFocusedTweetEditor = false
  6043.  
  6044. /**
  6045. * @param {HTMLElement} $focusedTweet
  6046. * @param {import("./types").IndividualTweetTimelineOptions} options
  6047. */
  6048. return async function tweakFocusedTweet($focusedTweet, options) {
  6049. let {observers} = options
  6050.  
  6051. if (!$focusedTweet) {
  6052. if (desktop) {
  6053. waitingForFocusedTweetEditor = false
  6054. disconnectObserver('tweet editor', observers)
  6055. }
  6056. return
  6057. }
  6058.  
  6059. tweakOwnFocusedTweet($focusedTweet)
  6060. restoreTweetInteractionsLinks($focusedTweet)
  6061.  
  6062. if (desktop && config.replaceLogo &&
  6063. !waitingForFocusedTweetEditor &&
  6064. !isObserving(observers, 'tweet editor')) {
  6065. waitingForFocusedTweetEditor = true
  6066. /** @type {HTMLElement} */
  6067. let $editorRoot
  6068. try {
  6069. $editorRoot = await getElement('.DraftEditor-root', {
  6070. context: $focusedTweet.parentElement,
  6071. name: 'tweet editor in focused tweet',
  6072. timeout: 500,
  6073. stopIf: () => !waitingForFocusedTweetEditor
  6074. })
  6075. } finally {
  6076. waitingForFocusedTweetEditor = false
  6077. }
  6078. if ($editorRoot) {
  6079. observeDesktopTweetEditorPlaceholder($editorRoot, {
  6080. name: 'tweet editor',
  6081. placeholder: getString('TWEET_YOUR_REPLY'),
  6082. observers,
  6083. })
  6084. }
  6085. }
  6086. }
  6087. })()
  6088.  
  6089. async function tweakFollowListPage() {
  6090. // These tabs are dynamic as "Followers you know" only appears when applicable
  6091. let $tabs = await getElement(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`, {
  6092. name: 'Following tabs',
  6093. stopIf: pageIsNot(currentPage),
  6094. })
  6095. if (!$tabs) return
  6096.  
  6097. let $subscriptionsTabLink = $tabs.querySelector('div[role="tablist"] a[href$="/subscriptions"]')
  6098. if ($subscriptionsTabLink) {
  6099. $body.classList.add('Subscriptions')
  6100. }
  6101.  
  6102. if (config.hideVerifiedNotificationsTab) {
  6103. let isVerifiedTabSelected = Boolean($tabs.querySelector('div[role="tablist"] > div:nth-child(1) > a[aria-selected="true"]'))
  6104. if (isVerifiedTabSelected) {
  6105. log('switching to Following tab')
  6106. let $followingTab = /** @type {HTMLAnchorElement} */ (
  6107. $tabs.querySelector(`div[role="tablist"] > div:nth-last-child(${$subscriptionsTabLink ? 3 : 2}) > a`)
  6108. )
  6109. $followingTab?.click()
  6110. }
  6111. }
  6112.  
  6113. if (config.twitterBlueChecks != 'ignore') {
  6114. observeTimeline(currentPage, {
  6115. classifyTweets: false,
  6116. })
  6117. }
  6118. }
  6119.  
  6120. async function tweakIndividualTweetPage() {
  6121. userSortedReplies = false
  6122. observeIndividualTweetTimeline(currentPage)
  6123.  
  6124. if (config.replaceLogo) {
  6125. (async () => {
  6126. let $headingText = await getElement(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} h2 span`, {
  6127. name: 'tweet thread heading',
  6128. stopIf: pageIsNot(currentPage)
  6129. })
  6130. if ($headingText && $headingText.textContent != getString('TWEET')) {
  6131. $headingText.textContent = getString('TWEET')
  6132. }
  6133. })()
  6134. }
  6135. }
  6136.  
  6137. function tweakListPage() {
  6138. observeTimeline(currentPage, {
  6139. hideHeadings: false,
  6140. })
  6141. }
  6142.  
  6143. async function tweakListsPage() {
  6144. if (config.hideMoreTweets) {
  6145. // Hide Discover new Lists
  6146. let $showMoreLink = await getElement('a[href="/i/lists/suggested"]', {
  6147. name: 'Show more link',
  6148. stopIf: pageIsNot(currentPage),
  6149. })
  6150. if (!$showMoreLink) return
  6151. let $timelineItem = $showMoreLink.closest('[data-testid="cellInnerDiv"]')
  6152. if (!$timelineItem) {
  6153. warn('could not find timeline item containing Show more link')
  6154. return
  6155. }
  6156. let $timelineItems = $timelineItem.parentElement.children
  6157. let showMoreIndex = Array.prototype.indexOf.call($timelineItems, $timelineItem)
  6158. for (let i = 1; i <= showMoreIndex + 2; i++) {
  6159. $timelineItems[i].classList.add('SuggestedContent')
  6160. }
  6161. }
  6162. }
  6163.  
  6164. async function tweakDesktopLogo() {
  6165. let $logoPath = await getElement(`h1 ${Selectors.X_LOGO_PATH}, h1 ${Selectors.X_DARUMA_LOGO_PATH}`, {
  6166. name: 'desktop nav logo',
  6167. timeout: 5000,
  6168. })
  6169. if ($logoPath) {
  6170. twitterLogo($logoPath)
  6171. }
  6172. }
  6173.  
  6174. async function tweakHomeIcon() {
  6175. let $homeIconPath = await getElement(`${Selectors.NAV_HOME_LINK} svg path`, {name: 'Home icon', stopIf: pageIsNot(currentPage)})
  6176. if ($homeIconPath) {
  6177. homeIcon($homeIconPath)
  6178. }
  6179. }
  6180.  
  6181. const tweakOwnFocusedTweet = (() => {
  6182. let waitingForAnalyticsUpsell = false
  6183.  
  6184. return async function tweakOwnFocusedTweet($focusedTweet) {
  6185. // Only your own focused Tweets have an analytics button
  6186. let $analyticsButton = $focusedTweet.querySelector('a[data-testid="analyticsButton"]')
  6187. if (!$analyticsButton) return
  6188.  
  6189. $analyticsButton.parentElement.classList.add('AnalyticsButton')
  6190.  
  6191. if (!config.hideTwitterBlueUpsells ||
  6192. waitingForAnalyticsUpsell ||
  6193. $focusedTweet.getAttribute('data-upselltagged')) return
  6194. waitingForAnalyticsUpsell = true
  6195. try {
  6196. let $accountAnalyticsUpsell = await getElement(':scope > div > div > div > div:has(a[href="/i/account_analytics"])', {
  6197. context: $focusedTweet,
  6198. name: 'account analytics upsell',
  6199. timeout: 200,
  6200. })
  6201. if ($accountAnalyticsUpsell) {
  6202. $accountAnalyticsUpsell.classList.add('PremiumUpsell')
  6203. $focusedTweet.setAttribute('data-upselltagged', 'true')
  6204. }
  6205. } finally {
  6206. waitingForAnalyticsUpsell = false
  6207. }
  6208. }
  6209. })()
  6210.  
  6211. /**
  6212. * Restores "Tweet" button text.
  6213. */
  6214. async function tweakTweetButton() {
  6215. let $tweetButton = await getElement(`${desktop ? 'div[data-testid="primaryColumn"]': 'main'} button[data-testid^="tweetButton"]`, {
  6216. name: 'tweet button',
  6217. stopIf: pageIsNot(currentPage),
  6218. })
  6219. if ($tweetButton) {
  6220. let $text = $tweetButton.querySelector('span > span')
  6221. if ($text) {
  6222. setTweetButtonText($text)
  6223. } else {
  6224. warn('could not find Tweet button text')
  6225. }
  6226. }
  6227. }
  6228.  
  6229. function tweakHomeTimelinePage() {
  6230. let $timelineTabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`)
  6231.  
  6232. // Hook for styling when on the separated tweets tab
  6233. $body.classList.toggle('SeparatedTweets', isOnSeparatedTweetsTimeline())
  6234.  
  6235. if ($timelineTabs == null) {
  6236. warn('could not find Home timeline tabs')
  6237. return
  6238. }
  6239.  
  6240. tweakTimelineTabs($timelineTabs)
  6241. if (mobile && isSafari && config.replaceLogo) {
  6242. processTwitterLogos(document.querySelector(Selectors.MOBILE_TIMELINE_HEADER))
  6243. }
  6244.  
  6245. function updateSelectedHomeTabIndex() {
  6246. let $selectedHomeTabLink = $timelineTabs.querySelector('div[role="tablist"] a[aria-selected="true"]')
  6247. if ($selectedHomeTabLink) {
  6248. selectedHomeTabIndex = Array.from($selectedHomeTabLink.parentElement.parentElement.children).indexOf($selectedHomeTabLink.parentElement)
  6249. log({selectedHomeTabIndex})
  6250. } else {
  6251. warn('could not find selected Home tab link')
  6252. selectedHomeTabIndex = -1
  6253. }
  6254. }
  6255.  
  6256. updateSelectedHomeTabIndex()
  6257.  
  6258. // If there are pinned lists, the timeline tabs <nav> will be replaced when they load
  6259. pageObservers.push(
  6260. observeElement($timelineTabs.parentElement, (mutations) => {
  6261. let timelineTabsReplaced = mutations.some(mutation => Array.from(mutation.removedNodes).includes($timelineTabs))
  6262. if (timelineTabsReplaced) {
  6263. log('Home timeline tabs replaced')
  6264. $timelineTabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`)
  6265. tweakTimelineTabs($timelineTabs)
  6266. }
  6267. }, 'Home timeline tabs nav container')
  6268. )
  6269.  
  6270. observeTimeline(currentPage, {
  6271. isTabbed: true,
  6272. onTabChanged: () => {
  6273. updateSelectedHomeTabIndex()
  6274. wasForYouTabSelected = selectedHomeTabIndex == 0
  6275. },
  6276. tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child',
  6277. })
  6278.  
  6279. if (desktop) {
  6280. observeDesktopHomeTimelineTweetBox()
  6281. }
  6282. }
  6283.  
  6284. async function tweakMobileComposeTweetPage() {
  6285. if (!config.replaceLogo && config.twitterBlueChecks == 'ignore') return
  6286.  
  6287. function observeUserTypeaheadDropdown($tweetTextareaContainer) {
  6288. if (!$tweetTextareaContainer) {
  6289. warn('could not find Tweet textarea container to observe user dropdown')
  6290. return
  6291. }
  6292.  
  6293. disconnectPageObserver('Tweet box typeahead dropdown container')
  6294. disconnectPageObserver('Tweet box typeahead dropdown')
  6295. let $dropdownContainer = $tweetTextareaContainer.parentElement.parentElement.parentElement.parentElement
  6296. /** @type {HTMLElement} */
  6297. let $typeaheadDropdown = $dropdownContainer.querySelector(':scope > [id^="typeaheadDropdown"]')
  6298. function observeDropdown() {
  6299. pageObservers.push(
  6300. observeElement($typeaheadDropdown, () => {
  6301. processBlueChecks($typeaheadDropdown)
  6302. }, 'Tweet box typeahead dropdown')
  6303. )
  6304. }
  6305. // If the list was re-rendered to display a dropdown for an additional
  6306. // Tweet, it will already be in the DOM.
  6307. if ($typeaheadDropdown) {
  6308. observeDropdown()
  6309. }
  6310. pageObservers.push(
  6311. observeElement($dropdownContainer, (mutations) => {
  6312. for (let mutation of mutations) {
  6313. if ($typeaheadDropdown &&
  6314. mutations.some(mutation => Array.from(mutation.removedNodes).includes($typeaheadDropdown))) {
  6315. disconnectPageObserver('Tweet box typeahead dropdown')
  6316. $typeaheadDropdown = null
  6317. }
  6318. for (let $addedNode of mutation.addedNodes) {
  6319. if ($addedNode instanceof HTMLElement &&
  6320. $addedNode.getAttribute('id')?.startsWith('typeaheadDropdown')) {
  6321. $typeaheadDropdown = $addedNode
  6322. observeDropdown()
  6323. }
  6324. }
  6325. }
  6326. }, 'Tweet box typeahead dropdown container')
  6327. )
  6328. }
  6329.  
  6330. let isReply = Boolean(document.querySelector('article[data-testid="tweet"]'))
  6331. if (isReply) {
  6332. // Restore old placeholder in Tweet textarea
  6333. if (config.replaceLogo) {
  6334. let $textarea = /** @type {HTMLTextAreaElement} */ (
  6335. document.querySelector('main div[data-testid^="tweetTextarea"] textarea')
  6336. )
  6337. if ($textarea) {
  6338. $textarea.placeholder = getString('TWEET_YOUR_REPLY')
  6339. } else {
  6340. warn('could not find Tweet textarea')
  6341. }
  6342. }
  6343. // Observe username typeahead dropdown in Tweet box
  6344. if (config.twitterBlueChecks != 'ignore') {
  6345. observeUserTypeaheadDropdown(document.querySelector('main div[data-testid^="tweetTextarea"]'))
  6346. }
  6347. } else {
  6348. let $mask = document.querySelector('[data-testid="twc-cc-mask"]')
  6349. let $tweetButtonText = document.querySelector('main button[data-testid^="tweetButton"] span > span')
  6350. if ($mask && $tweetButtonText) {
  6351. // We need to re-apply tweaks every time the child list changes. When
  6352. // you use the username typeahead dropdown in any Tweet box, the list
  6353. // re-renders so it's the only Tweet while the dropdown is open.
  6354. observeElement($mask.nextElementSibling, () => {
  6355. let $containers = document.querySelectorAll('main div[data-testid^="tweetTextarea"]')
  6356. $containers.forEach(($container, index) => {
  6357. if (config.replaceLogo) {
  6358. let $textarea = $container.querySelector('textarea')
  6359. $textarea.placeholder = getString(index == 0 ? 'WHATS_HAPPENING' : 'ADD_ANOTHER_TWEET')
  6360. }
  6361. if (index == 0 && config.twitterBlueChecks) {
  6362. observeUserTypeaheadDropdown($container)
  6363. }
  6364. })
  6365. // Don't update the Tweet button if the list was re-rendered to display
  6366. // a user dropdown, in which case it will already be in the DOM.
  6367. if (config.replaceLogo && !document.querySelector('main [id^="typeaheadDropdown"]')) {
  6368. $tweetButtonText.textContent = getString($containers.length == 1 ? 'TWEET' : 'TWEET_ALL')
  6369. }
  6370. }, 'Tweets container')
  6371. } else {
  6372. warn('could not find all elements needed to tweak the Compose Tweet page', {$mask, $tweetButtonText})
  6373. }
  6374. }
  6375. }
  6376.  
  6377. async function tweakMobileMediaViewerPage() {
  6378. let $timeline = await getElement('[data-testid="vss-scroll-view"] > div', {
  6379. name: 'media viewer timeline',
  6380. stopIf: () => !URL_MEDIAVIEWER_RE.test(location.pathname),
  6381. })
  6382. if (!$timeline) return
  6383.  
  6384. /** @param {HTMLVideoElement} $video */
  6385. function processVideo($video) {
  6386. if ($video.loop != config.preventNextVideoAutoplay) {
  6387. $video.loop = config.preventNextVideoAutoplay
  6388. }
  6389. }
  6390.  
  6391. // Process initial contents
  6392. let $videos = $timeline.querySelectorAll('video')
  6393. log($videos.length, `initial video${s($videos.length)}`)
  6394. $videos.forEach(processVideo)
  6395. if (config.twitterBlueChecks != 'ignore') {
  6396. processBlueChecks($timeline)
  6397. }
  6398.  
  6399. pageObservers.push(
  6400. observeElement($timeline, (mutations) => {
  6401. for (let mutation of mutations) {
  6402. for (let $addedNode of mutation.addedNodes) {
  6403. if (!($addedNode instanceof HTMLElement) || $addedNode.nodeName != 'DIV') continue
  6404. let $video = $addedNode.querySelector('video')
  6405. if ($video) {
  6406. processVideo($video)
  6407. }
  6408. if (config.twitterBlueChecks != 'ignore') {
  6409. let $videoInfo = $addedNode.querySelector('[data-testid^="immersive-tweet-ui-content-container"]')
  6410. if ($videoInfo) {
  6411. processBlueChecks($addedNode)
  6412. }
  6413. }
  6414. }
  6415. }
  6416. }, 'media viewer timeline', {childList: true, subtree: true})
  6417. )
  6418. }
  6419.  
  6420. async function tweakTimelineTabs($timelineTabs) {
  6421. $timelineTabs.classList.add('TimelineTabs')
  6422. let $followingTabLink = /** @type {HTMLElement} */ ($timelineTabs.querySelector('div[role="tablist"] > div:nth-child(2) > a'))
  6423.  
  6424. if (config.alwaysUseLatestTweets && !document.title.startsWith(separatedTweetsTimelineTitle)) {
  6425. let isForYouTabSelected = Boolean($timelineTabs.querySelector('div[role="tablist"] > div:first-child > a[aria-selected="true"]'))
  6426. if (isForYouTabSelected && (!wasForYouTabSelected || config.hideForYouTimeline)) {
  6427. log('switching to Following timeline')
  6428. $followingTabLink.click()
  6429. wasForYouTabSelected = false
  6430. } else {
  6431. wasForYouTabSelected = isForYouTabSelected
  6432. }
  6433. }
  6434.  
  6435. if (shouldShowSeparatedTweetsTab()) {
  6436. let $newTab = /** @type {HTMLElement} */ ($timelineTabs.querySelector('#tnt_separated_tweets_tab'))
  6437. if ($newTab) {
  6438. log('separated tweets timeline tab already present')
  6439. $newTab.querySelector('span').textContent = separatedTweetsTimelineTitle
  6440. }
  6441. else {
  6442. log('inserting separated tweets tab')
  6443. $newTab = /** @type {HTMLElement} */ ($followingTabLink.parentElement.cloneNode(true))
  6444. $newTab.id = 'tnt_separated_tweets_tab'
  6445. $newTab.querySelector('span').textContent = separatedTweetsTimelineTitle
  6446. let $link = $newTab.querySelector('a')
  6447. $link.removeAttribute('aria-selected')
  6448.  
  6449. // This script assumes navigation has occurred when the document title
  6450. // changes, so by changing the title we fake navigation to a non-existent
  6451. // page representing the separated tweets timeline.
  6452. $link.addEventListener('click', (e) => {
  6453. e.preventDefault()
  6454. e.stopPropagation()
  6455. if (!document.title.startsWith(separatedTweetsTimelineTitle)) {
  6456. // The separated tweets tab belongs to the Following tab
  6457. let isFollowingTabSelected = Boolean($timelineTabs.querySelector('div[role="tablist"] > div:nth-child(2) > a[aria-selected="true"]'))
  6458. if (!isFollowingTabSelected) {
  6459. log('switching to the Following tab for separated tweets')
  6460. $followingTabLink.click()
  6461. }
  6462. setTitle(separatedTweetsTimelineTitle)
  6463. }
  6464. window.scrollTo({top: 0})
  6465. })
  6466. $followingTabLink.parentElement.insertAdjacentElement('afterend', $newTab)
  6467.  
  6468. // Return to the Home timeline when any other tab is clicked
  6469. $followingTabLink.parentElement.parentElement.addEventListener('click', () => {
  6470. if (location.pathname == '/home' && !document.title.startsWith(getString('HOME'))) {
  6471. log('setting title to Home')
  6472. homeNavigationIsBeingUsed = true
  6473. setTitle(getString('HOME'))
  6474. }
  6475. })
  6476.  
  6477. // Return to the Home timeline when the Home nav link is clicked
  6478. let $homeNavLink = await getElement(Selectors.NAV_HOME_LINK, {
  6479. name: 'home nav link',
  6480. stopIf: pathIsNot(currentPath),
  6481. })
  6482. if ($homeNavLink && !$homeNavLink.dataset.tweakNewTwitterListener) {
  6483. $homeNavLink.addEventListener('click', () => {
  6484. homeNavigationIsBeingUsed = true
  6485. if (location.pathname == '/home' && !document.title.startsWith(getString('HOME'))) {
  6486. setTitle(getString('HOME'))
  6487. }
  6488. })
  6489. $homeNavLink.dataset.tweakNewTwitterListener = 'true'
  6490. }
  6491. }
  6492. } else {
  6493. removeMobileTimelineHeaderElements()
  6494. }
  6495. }
  6496.  
  6497. function tweakNotificationsPage() {
  6498. let $navigationTabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`)
  6499. if ($navigationTabs == null) {
  6500. warn('could not find Notifications tabs')
  6501. return
  6502. }
  6503.  
  6504. if (config.hideVerifiedNotificationsTab) {
  6505. let isVerifiedTabSelected = Boolean($navigationTabs.querySelector('div[role="tablist"] > div:nth-child(2) > a[aria-selected="true"]'))
  6506. if (isVerifiedTabSelected) {
  6507. log('switching to All tab')
  6508. let $allTab = /** @type {HTMLAnchorElement} */ (
  6509. $navigationTabs.querySelector('div[role="tablist"] > div:nth-child(1) > a')
  6510. )
  6511. $allTab?.click()
  6512. }
  6513. }
  6514.  
  6515. if (config.twitterBlueChecks != 'ignore' || config.restoreLinkHeadlines) {
  6516. observeTimeline(currentPage, {
  6517. isTabbed: true,
  6518. tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child',
  6519. })
  6520. }
  6521. }
  6522.  
  6523. async function tweakProfilePage() {
  6524. let $initialContent = await getElement(desktop ? Selectors.PRIMARY_COLUMN : Selectors.MOBILE_TIMELINE_HEADER, {
  6525. name: 'initial profile content',
  6526. stopIf: pageIsNot(currentPage),
  6527. })
  6528. if (!$initialContent) return
  6529.  
  6530. if (config.twitterBlueChecks != 'ignore') {
  6531. processBlueChecks($initialContent)
  6532. }
  6533.  
  6534. let tab = currentPath.match(URL_PROFILE_RE)?.[2] || 'tweets'
  6535. log(`on ${tab} tab`)
  6536. observeTimeline(currentPage, {
  6537. isUserTimeline: tab == 'affiliates'
  6538. })
  6539.  
  6540. getElement('a[href="/settings/profile"]', {
  6541. name: 'edit profile button',
  6542. stopIf: pageIsNot(currentPage),
  6543. timeout: 500,
  6544. }).then($editProfileButton => {
  6545. $body.classList.toggle('OwnProfile', Boolean($editProfileButton))
  6546. if (config.hideTwitterBlueUpsells) {
  6547. // This selector is _extremely_ specific to try to avoid false positives
  6548. getElement(mobile ? (
  6549. '[data-testid="primaryColumn"] > div > div > div > div > div > div > div > div > div:has(> div > div > div > a[href^="/i/premium"])'
  6550. ) : (
  6551. '[data-testid="primaryColumn"] > div > div > div > div > div > div:has(> div > div > div > a[href^="/i/premium"])'
  6552. ), {
  6553. name: "you aren't verified yet premium upsell",
  6554. stopIf: pageIsNot(currentPage),
  6555. timeout: 200,
  6556. }).then($upsell => {
  6557. if ($upsell) {
  6558. $upsell.classList.add('PremiumUpsell')
  6559. }
  6560. })
  6561. }
  6562. })
  6563. let $headerVerifiedIcon = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.TIMELINE_HEADING} [data-testid="icon-verified"]`)
  6564. $body.classList.toggle('PremiumProfile', Boolean($headerVerifiedIcon))
  6565.  
  6566. if (config.replaceLogo || config.hideSubscriptions) {
  6567. let $profileTabs = await getElement(`${Selectors.PRIMARY_COLUMN} nav`, {
  6568. name: 'profile tabs',
  6569. stopIf: pageIsNot(currentPage),
  6570. })
  6571. if (!$profileTabs) return
  6572. // The Profile tabs <nav> can be replaced
  6573. pageObservers.push(
  6574. observeElement($profileTabs.parentElement, async (mutations) => {
  6575. if (mutations.length > 0) {
  6576. let $newProfileTabs = findAddedNode(mutations, ($el) => $el instanceof HTMLElement && $el.tagName == 'NAV')
  6577. if ($newProfileTabs == null) return
  6578. $profileTabs = /** @type {HTMLElement} */ ($newProfileTabs)
  6579. }
  6580. if (config.replaceLogo) {
  6581. let $tweetsTabText = await getElement('[data-testid="ScrollSnap-List"] > [role="presentation"]:first-child div[dir] > span:first-child', {
  6582. context: $profileTabs,
  6583. name: 'Tweets tab text',
  6584. stopIf: pageIsNot(currentPage),
  6585. })
  6586. if ($tweetsTabText && $tweetsTabText.textContent != getString('TWEETS')) {
  6587. $tweetsTabText.textContent = getString('TWEETS')
  6588. }
  6589. }
  6590. if (config.hideSubscriptions) {
  6591. let $subscriptionsTabLink = await getElement('a[href$="/superfollows"]', {
  6592. context: $profileTabs,
  6593. name: 'Subscriptions tab link',
  6594. stopIf: pageIsNot(currentPage),
  6595. timeout: 1000,
  6596. })
  6597. if ($subscriptionsTabLink) {
  6598. $subscriptionsTabLink.parentElement.classList.add('SubsTab')
  6599. }
  6600. }
  6601. }, 'profile tabs', {childList: true})
  6602. )
  6603. }
  6604. }
  6605.  
  6606. /**
  6607. * @param {Element} $dropdownItem
  6608. * @param {string} dropdownItemSelector
  6609. * @param {import("./types").LocaleKey} localeKey
  6610. */
  6611. async function tweakRetweetDropdown($dropdownItem, dropdownItemSelector, localeKey) {
  6612. log('tweaking Retweet/Quote Tweet dropdown')
  6613.  
  6614. if (desktop) {
  6615. $dropdownItem = await getElement(`
  6616. #layers div[data-testid="Dropdown"] ${dropdownItemSelector}
  6617. `, {
  6618. name: 'rendered menu item',
  6619. timeout: 100,
  6620. })
  6621. if (!$dropdownItem) return
  6622. }
  6623.  
  6624. let $text = $dropdownItem.querySelector('div[dir] > span')
  6625. if ($text) $text.textContent = getString(localeKey)
  6626.  
  6627. let $quoteTweetText = $dropdownItem.nextElementSibling?.querySelector('div[dir] > span')
  6628. if ($quoteTweetText) $quoteTweetText.textContent = getString('QUOTE_TWEET')
  6629. }
  6630.  
  6631. function tweakSearchPage() {
  6632. let $searchTabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`)
  6633. if ($searchTabs != null) {
  6634. if (config.defaultToLatestSearch) {
  6635. let isTopTabSelected = Boolean($searchTabs.querySelector('div[role="tablist"] > div:nth-child(1) > a[aria-selected="true"]'))
  6636. if (isTopTabSelected) {
  6637. log('switching to Latest tab')
  6638. let $latestTab = /** @type {HTMLAnchorElement} */ (
  6639. $searchTabs.querySelector('div[role="tablist"] > div:nth-child(2) > a')
  6640. )
  6641. $latestTab?.click()
  6642. }
  6643. }
  6644. } else {
  6645. warn('could not find Search tabs')
  6646. }
  6647.  
  6648. observeTimeline(currentPage, {
  6649. hideHeadings: false,
  6650. isTabbed: true,
  6651. tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child',
  6652. })
  6653.  
  6654. if (desktop) {
  6655. let $emptyFirstSidebarItem = document.querySelector(`${Selectors.SIDEBAR_WRAPPERS} > div:first-child:empty`)
  6656. if ($emptyFirstSidebarItem) {
  6657. log('removing empty first sidebar item from Search sidebar')
  6658. $emptyFirstSidebarItem.remove()
  6659. }
  6660. }
  6661. }
  6662.  
  6663. function tweakTweetEngagementPage() {
  6664. if (config.replaceLogo) {
  6665. let $headingText = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} h2 span`)
  6666. if ($headingText) {
  6667. if ($headingText.textContent != getString('TWEET_INTERACTIONS')) {
  6668. $headingText.textContent = getString('TWEET_INTERACTIONS')
  6669. }
  6670. } else {
  6671. warn('could not find Post engagement heading')
  6672. }
  6673. }
  6674.  
  6675. let $tabs = document.querySelector(`${mobile ? Selectors.MOBILE_TIMELINE_HEADER : Selectors.PRIMARY_COLUMN} nav`)
  6676. if ($tabs == null) {
  6677. warn('could not find Post engagement tabs')
  6678. return
  6679. }
  6680.  
  6681. if (tweetInteractionsTab) {
  6682. log('switching to tab', tweetInteractionsTab)
  6683. let $tab = /** @type {HTMLAnchorElement} */ (
  6684. $tabs.querySelector(`div[role="tablist"] > div:nth-child(${tweetInteractionsTab}) > a`)
  6685. )
  6686. $tab?.click()
  6687. tweetInteractionsTab = null
  6688. }
  6689.  
  6690. if (config.replaceLogo) {
  6691. let $quoteTweetsTabText = $tabs.querySelector('div[role="tablist"] > div:nth-child(1) div[dir] > span')
  6692. if ($quoteTweetsTabText) $quoteTweetsTabText.textContent = getString('QUOTE_TWEETS')
  6693. let $retweetsTabText = $tabs.querySelector('div[role="tablist"] > div:nth-child(2) div[dir] > span')
  6694. if ($retweetsTabText) $retweetsTabText.textContent = getString('RETWEETS')
  6695. }
  6696.  
  6697. if (config.twitterBlueChecks != 'ignore') {
  6698. observeTimeline(currentPage, {classifyTweets: false})
  6699. }
  6700. }
  6701. //#endregion
  6702.  
  6703. //#region Main
  6704. async function main() {
  6705. let $settings = /** @type {HTMLScriptElement} */ (document.querySelector('script#tnt_settings'))
  6706. if ($settings) {
  6707. try {
  6708. Object.assign(config, JSON.parse($settings.innerText))
  6709. } catch(e) {
  6710. error('error parsing initial settings', e)
  6711. }
  6712. }
  6713.  
  6714. if (config.debug) {
  6715. debug = true
  6716. }
  6717.  
  6718. // Don't run on URLs used for OAuth
  6719. if (location.pathname.startsWith('/i/oauth2/authorize') ||
  6720. location.pathname.startsWith('/oauth/authorize')) {
  6721. log('Not running on OAuth URL')
  6722. return
  6723. }
  6724.  
  6725. // Don't run if we're redirecting to twitter.com
  6726. if (redirectToTwitter()) {
  6727. return
  6728. }
  6729.  
  6730. if ($settings) {
  6731. let settingsChangeObserver = new MutationObserver(() => {
  6732. /** @type {Partial<import("./types").Config>} */
  6733. let configChanges
  6734. try {
  6735. configChanges = JSON.parse($settings.innerText)
  6736. } catch(e) {
  6737. error('error parsing incoming settings change', e)
  6738. return
  6739. }
  6740.  
  6741. if ('debug' in configChanges) {
  6742. log('disabling debug mode')
  6743. debug = configChanges.debug
  6744. log('enabled debug mode')
  6745. configureThemeCss()
  6746. return
  6747. }
  6748.  
  6749. Object.assign(config, configChanges)
  6750. configChanged(configChanges)
  6751. })
  6752. settingsChangeObserver.observe($settings, {childList: true})
  6753. }
  6754.  
  6755. observeTitle()
  6756. observeFavicon()
  6757.  
  6758. let $loadingStyle
  6759. if (config.replaceLogo) {
  6760. getElement('html', {name: 'html element'}).then(($html) => {
  6761. $loadingStyle = document.createElement('style')
  6762. $loadingStyle.dataset.insertedBy = 'control-panel-for-twitter'
  6763. $loadingStyle.dataset.role = 'loading-logo'
  6764. $loadingStyle.textContent = dedent(`
  6765. ${Selectors.X_LOGO_PATH} {
  6766. fill: ${isSafari ? 'transparent' : THEME_BLUE};
  6767. d: path("${Svgs.TWITTER_LOGO_PATH}");
  6768. }
  6769. .tnt_logo {
  6770. fill: ${THEME_BLUE};
  6771. }
  6772. `)
  6773. $html.appendChild($loadingStyle)
  6774. })
  6775.  
  6776. if (isSafari) {
  6777. getElement(Selectors.X_LOGO_PATH, {name: 'pre-loading indicator logo', timeout: 1000}).then(($logoPath) => {
  6778. if ($logoPath) {
  6779. twitterLogo($logoPath)
  6780. }
  6781. })
  6782. }
  6783. }
  6784.  
  6785. let $appWrapper = await getElement('#layers + div', {name: 'app wrapper'})
  6786.  
  6787. $html = document.querySelector('html')
  6788. $body = document.body
  6789. $reactRoot = document.querySelector('#react-root')
  6790. lang = $html.lang
  6791. dir = $html.dir
  6792. ltr = dir == 'ltr'
  6793. let lastFlexDirection
  6794.  
  6795. observeElement($appWrapper, () => {
  6796. let flexDirection = getComputedStyle($appWrapper).flexDirection
  6797.  
  6798. mobile = flexDirection == 'column'
  6799. desktop = !mobile
  6800.  
  6801. /** @type {'mobile' | 'desktop'} */
  6802. let version = mobile ? 'mobile' : 'desktop'
  6803.  
  6804. if (version != config.version) {
  6805. log('setting version to', version)
  6806. config.version = version
  6807. // Let the options page know which version is being used
  6808. storeConfigChanges({version})
  6809. }
  6810.  
  6811. if (lastFlexDirection == null) {
  6812. log('initial config', {config, lang, version})
  6813.  
  6814. // One-time setup
  6815. checkReactNativeStylesheet()
  6816. observeBodyBackgroundColor()
  6817. let initialThemeColor = getThemeColorFromState()
  6818. if (initialThemeColor) {
  6819. themeColor = initialThemeColor
  6820. }
  6821. if (desktop) {
  6822. fontSize = $html.style.fontSize
  6823. if (!fontSize) {
  6824. warn('initial fontSize not set on <html>')
  6825. }
  6826. }
  6827.  
  6828. // Repeatable configuration setup
  6829. configureSeparatedTweetsTimelineTitle()
  6830. configureCss()
  6831. configureDynamicCss()
  6832. configureThemeCss()
  6833. configureCustomCss()
  6834. observePopups()
  6835. observeSideNavTweetButton()
  6836.  
  6837. // Start taking action on page changes
  6838. observingPageChanges = true
  6839.  
  6840. // Delay removing loading icon styles to avoid Flash of X
  6841. if ($loadingStyle) {
  6842. setTimeout(() => $loadingStyle.remove(), 1000)
  6843. }
  6844. }
  6845. else if (flexDirection != lastFlexDirection) {
  6846. configChanged({version})
  6847. }
  6848.  
  6849. $body.classList.toggle('Mobile', mobile)
  6850. $body.classList.toggle('Desktop', desktop)
  6851.  
  6852. lastFlexDirection = flexDirection
  6853. }, 'app wrapper class attribute for version changes (mobile ↔ desktop)', {
  6854. attributes: true,
  6855. attributeFilter: ['class']
  6856. })
  6857. }
  6858.  
  6859. /**
  6860. * @param {Partial<import("./types").Config>} changes
  6861. */
  6862. function configChanged(changes) {
  6863. log('config changed', changes)
  6864.  
  6865. if ('redirectToTwitter' in changes && redirectToTwitter()) {
  6866. return
  6867. }
  6868.  
  6869. if ('version' in changes) {
  6870. fontSize = desktop ? $html.style.fontSize : null
  6871. }
  6872.  
  6873. configureCss()
  6874. configureFont()
  6875. configureDynamicCss()
  6876. configureThemeCss()
  6877. configureCustomCss()
  6878. observePopups()
  6879. observeSideNavTweetButton()
  6880.  
  6881. if ('replaceLogo' in changes || 'hideNotifications' in changes) {
  6882. observeFavicon.forceUpdate(getNotificationCount() > 0)
  6883. }
  6884. // Store the current notification count if hiding notifications was enabled
  6885. if ('hideNotifications' in changes && config.hideNotifications != 'ignore') {
  6886. hiddenNotificationCount = currentNotificationCount
  6887. }
  6888.  
  6889. let navigationTriggered = (
  6890. configureSeparatedTweetsTimelineTitle() ||
  6891. checkforDisabledHomeTimeline()
  6892. )
  6893.  
  6894. if ('hideNotifications' in changes) {
  6895. // Hide or show the notification count in the title. The title will already
  6896. // have been updated if other navigation was triggered.
  6897. if (!navigationTriggered) {
  6898. setTitle(currentPage)
  6899. navigationTriggered = true
  6900. }
  6901. // Clear the stored notification count if hiding notifications was disabled
  6902. if (config.hideNotifications == 'ignore') {
  6903. hiddenNotificationCount = ''
  6904. }
  6905. }
  6906.  
  6907. // Only re-process the current page if navigation wasn't already triggered
  6908. // while applying config changes.
  6909. if (!navigationTriggered) {
  6910. processCurrentPage()
  6911. }
  6912. }
  6913.  
  6914. main()
  6915. //#endregion
  6916.  
  6917. }()