Compare commits

...

4 Commits

Author SHA1 Message Date
Jonny Buchanan
b3076019c8 - Added checkSocialContext to TimelineOptions instead of hardcoding it for profile timelines
- Fixed Community Tweets being treated as Retweets
- Fixed handling timelines in Communities tabs
- Replaced hardcoding of the primary column selector

Options:
- Fixed options textarea styles
- Fixed background in macOS Safari options popup in dark mode
- Apply other settings changes after options input changes
2025-06-15 18:55:44 +10:00
Jonny Buchanan
331f79215b Bump the version number in options.html so it's more obvious if we're using an older version 2025-06-14 15:41:32 +10:00
Jonny Buchanan
c6ce159f34 Cache processing of all Tweet timeline item handling 2025-06-14 15:41:08 +10:00
Jonny Buchanan
33fb01ff22 Fixed Retweeted Quote Tweets not appearing in the Retweets timeline 2025-06-14 15:40:36 +10:00
5 changed files with 120 additions and 74 deletions

View File

@ -129,7 +129,6 @@ section.group > label {
section.group > section > * {
color: rgb(95, 99, 104);
padding-left: 0;
}
p {
@ -148,12 +147,19 @@ section.group > section > p {
section.textarea {
display: flex;
justify-content: space-between;
padding: 4px 12px 4px 0;
margin: 8px 0;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
section.textarea > textarea {
margin: 8px 12px 12px 0;
}
section.textarea > button {
margin: 0 12px 12px 0;
}
section.textarea > p {
margin-right: 12px;
}
section.textarea button {
@ -162,7 +168,9 @@ section.textarea button {
textarea {
resize: vertical;
padding: 4px;
}
/* Prevent zooming */
body.iOS.safari textarea {
font-size: 16px;
@ -498,6 +506,9 @@ body.iOS.safari .checkbox input:focus + .toggle {
}
/* Safari dark mode overrides */
body.macOS.safari {
background-color: transparent;
}
body.macOS.safari p {
color: rgb(184, 184, 184) !important;
}

View File

@ -796,7 +796,7 @@
</section>
</section>
<div id="version">v4.12.1<span id="debugCountdown"></span></div>
<div id="version">v4.999<span id="debugCountdown"></span></div>
</form>
<script src="options.js" type="module"></script>
</body>

View File

@ -186,6 +186,7 @@ const defaultConfig = {
// Default based on the platform if the main script hasn't run on Twitter yet
version: /(Android|iP(ad|hone))/.test(navigator.userAgent) ? 'mobile' : 'desktop',
}
//#endregion
//#region Config & variables
/**
@ -290,15 +291,18 @@ function onFormChanged(/** @type {Event} */ e) {
if (e.target instanceof HTMLTextAreaElement) return
/** @type {import("./types").StoredConfig} */
let changedConfig = {}
let changedConfig = Object.create(null)
/** @type {Partial<import("./types").UserSettings>} */
let changedSettings = {}
let changedSettings = Object.create(null)
// Handle input
let $el = /** @type {HTMLInputElement} */ (e.target)
if ($el.type == 'checkbox') {
// All internal config is currently checkbox toggles
if (INTERNAL_CONFIG_FORM_KEYSET.has($el.name)) {
config[$el.name] = changedConfig[$el.name] = $el.checked
}
// Checkbox group toggle
else if (checkboxGroups.has($el.name)) {
checkboxGroups.get($el.name).forEach(checkboxName => {
config.settings[checkboxName] = changedSettings[checkboxName] = $el.checked
@ -306,24 +310,23 @@ function onFormChanged(/** @type {Event} */ e) {
})
$el.indeterminate = false
}
// Individual checkbox toggle
else {
// Individual checkbox change
config.settings[$el.name] = changedSettings[$el.name] = $el.checked
// Don't try to redirect the Home timeline to Notifications if both are disabled
if ($el.name == 'hideNotifications' &&
$el.checked &&
config.settings.disabledHomeTimelineRedirect == 'notifications') {
let key = 'disabledHomeTimelineRedirect'
$form.elements[key].value = config.settings[key] = changedSettings[key] = 'messages'
}
updateCheckboxGroups()
}
} else {
config.settings[$el.name] = changedSettings[$el.name] = $el.value
}
// Apply other settings changes based on input
// Don't try to redirect the Home timeline to Notifications if both are disabled
if (changedSettings.hideNotifications &&
config.settings.disabledHomeTimelineRedirect == 'notifications') {
let key = 'disabledHomeTimelineRedirect'
$form.elements[key].value = config.settings[key] = changedSettings[key] = 'messages'
}
updateDisplay()
if (Object.keys(changedSettings).length > 0) {
changedConfig.settings = changedSettings

137
script.js
View File

@ -2701,7 +2701,7 @@ async function observeDesktopComposeTweetModal($popup) {
* tab and re-added when you navigate to another Home timeline tab.
*/
async function observeDesktopHomeTimelineTweetBox() {
let $container = await getElement('div[data-testid="primaryColumn"] > div', {
let $container = await getElement(`${Selectors.PRIMARY_COLUMN} > div`, {
name: 'Home timeline Tweet box container',
stopIf: pageIsNot(currentPage),
})
@ -2782,7 +2782,7 @@ async function observeDesktopModalTimeline($popup) {
function observeModalTimelineItems($timeline) {
let seen = new Map()
observeElement($timeline, () => {
onIndividualTweetTimelineChange($timeline, {observers: modalObservers, seen})
onIndividualTweetTimelineChange($timeline, seen, {observers: modalObservers})
}, {
name: 'modal timeline',
observers: modalObservers,
@ -2796,7 +2796,7 @@ async function observeDesktopModalTimeline($popup) {
log('modal timeline replaced')
seen = new Map()
observeElement($newTimeline, () => {
onIndividualTweetTimelineChange($newTimeline, {observers: modalObservers, seen})
onIndividualTweetTimelineChange($newTimeline, seen, {observers: modalObservers})
}, {
name: 'modal timeline',
observers: modalObservers,
@ -3101,7 +3101,7 @@ async function observeIndividualTweetTimeline(page) {
function observeTimelineItems($timeline) {
let seen = new WeakMap()
observeElement($timeline, () => {
onIndividualTweetTimelineChange($timeline, {observers: pageObservers, seen})
onIndividualTweetTimelineChange($timeline, seen, {observers: pageObservers})
}, {
leading: true,
name: 'individual tweet timeline',
@ -3335,8 +3335,9 @@ async function observeTimeline(page, options = {}) {
* @param {HTMLElement} $timeline
*/
function observeTimelineItems($timeline) {
let seen = new WeakMap()
observeElement($timeline, () => {
onTimelineChange($timeline, page, options)
onTimelineChange($timeline, page, seen, options)
}, {
leading: true,
name: 'timeline',
@ -3353,8 +3354,9 @@ async function observeTimeline(page, options = {}) {
let $newTimeline = $addedNode
log('tab changed')
onTabChanged?.()
seen = new WeakMap()
observeElement($newTimeline, () => {
onTimelineChange($newTimeline, page, options)
onTimelineChange($newTimeline, page, seen, options)
}, {
leading: true,
name: 'timeline',
@ -4901,10 +4903,11 @@ function getTweetType($tweet, checkSocialContext = false) {
if ($tweet.closest(Selectors.PROMOTED_TWEET_CONTAINER)) {
return 'PROMOTED_TWEET'
}
// Assume social context tweets are Retweets
if ($tweet.querySelector('[data-testid="socialContext"]')) {
// Assume social context tweets are Retweets if we're not checking
if (checkSocialContext) {
let svgPath = $tweet.querySelector('svg path')?.getAttribute('d') ?? ''
if (svgPath.startsWith('M7.471 21H.472l.029-1.027c.184')) return 'COMMUNITY_TWEET'
if (svgPath.startsWith('M7 4.5C7 3.12 8.12 2 9.5 2h5C1')) return 'PINNED_TWEET'
}
// Quoted tweets from accounts you blocked or muted are displayed as an
@ -5242,12 +5245,12 @@ function isReplyToPreviousTweet($tweet) {
/**
* @param {HTMLElement} $timeline
* @param {WeakMap<Element, import("./types").SeenTweetDetails>} seen
* @param {import("./types").IndividualTweetTimelineOptions} options
*/
function onIndividualTweetTimelineChange($timeline, options) {
function onIndividualTweetTimelineChange($timeline, seen, options) {
let startTime = Date.now()
let {seen} = options
let itemTypes = {}
let hiddenItemCount = 0
let hiddenItemTypes = {}
@ -5535,11 +5538,17 @@ function onPopup($popup) {
/**
* @param {HTMLElement} $timeline
* @param {string} page
* @param {WeakMap<Element, import("./types").SeenTweetDetails>} seen
* @param {import("./types").TimelineOptions?} options
*/
function onTimelineChange($timeline, page, options = {}) {
function onTimelineChange($timeline, page, seen, options = {}) {
let startTime = Date.now()
let {classifyTweets = true, hideHeadings = true, isUserTimeline = false} = options
let {
checkSocialContext = false,
classifyTweets = true,
hideHeadings = true,
isUserTimeline = false
} = options
let isOnHomeTimeline = isOnHomeTimelinePage()
let isOnListTimeline = isOnListPage()
@ -5559,6 +5568,7 @@ function onTimelineChange($timeline, page, options = {}) {
let itemTypes = {}
let hiddenItemCount = 0
let hiddenItemTypes = {}
let processedCount = 0
/** @type {?boolean} */
let hidPreviousItem = null
@ -5566,6 +5576,11 @@ function onTimelineChange($timeline, page, options = {}) {
let changes = []
for (let $item of $timeline.children) {
if (seen.has($item)) {
hidPreviousItem = seen.get($item).hidden
continue
}
/** @type {?import("./types").TimelineItemType} */
let itemType = null
/** @type {?boolean} */
@ -5578,7 +5593,7 @@ function onTimelineChange($timeline, page, options = {}) {
let isBlueTweet = false
if ($tweet != null) {
itemType = getTweetType($tweet, isOnProfileTimeline)
itemType = getTweetType($tweet, checkSocialContext)
if (timelineHasSpecificHandling) {
isReply = isReplyToPreviousTweet($tweet)
if (isReply && hidPreviousItem != null) {
@ -5645,7 +5660,7 @@ function onTimelineChange($timeline, page, options = {}) {
}
if (!timelineHasSpecificHandling) {
if (itemType != null) {
if (!hideItem && itemType != null) {
hideItem = shouldHideOtherTimelineItem(itemType)
}
}
@ -5693,6 +5708,8 @@ function onTimelineChange($timeline, page, options = {}) {
}
hidPreviousItem = hideItem
seen.set($item, {itemType, hidden: hideItem})
processedCount++
}
for (let change of changes) {
@ -5701,7 +5718,7 @@ function onTimelineChange($timeline, page, options = {}) {
if (debug && debugLogTimelineStats) {
log(
`processed ${$timeline.children.length} timeline item${s($timeline.children.length)} in ${Date.now() - startTime}ms`,
`processed ${processedCount} new timeline item${s(processedCount)} in ${Date.now() - startTime}ms`,
itemTypes, `hid ${hiddenItemCount}`, hiddenItemTypes
)
}
@ -5786,21 +5803,27 @@ function onTitleChange(title) {
)
if (newPage == currentPage) {
log(`ignoring duplicate title change`)
// Navigation within the Compose Tweet modal triggers duplcate title changes
if (isDesktopComposeTweetModalOpen) {
if (currentPath == ModalPaths.COMPOSE_TWEET && COMPOSE_TWEET_MODAL_PAGES.has(location.pathname)) {
log('navigated away from Compose Tweet editor')
disconnectObservers(modalObservers, 'modal')
}
else if (COMPOSE_TWEET_MODAL_PAGES.has(currentPath) && location.pathname == ModalPaths.COMPOSE_TWEET) {
log('navigated back to Compose Tweet editor')
observeDesktopComposeTweetModal($desktopComposeTweetModalPopup)
if (isOnCommunitiesPage() &&
URL_COMMUNITIES_RE.test(location.pathname) &&
currentPath != location.pathname) {
log('navigated between Communities tabs (no title change)')
} else {
log(`ignoring duplicate title change`)
// Navigation within the Compose Tweet modal triggers duplcate title changes
if (isDesktopComposeTweetModalOpen) {
if (currentPath == ModalPaths.COMPOSE_TWEET && COMPOSE_TWEET_MODAL_PAGES.has(location.pathname)) {
log('navigated away from Compose Tweet editor')
disconnectObservers(modalObservers, 'modal')
}
else if (COMPOSE_TWEET_MODAL_PAGES.has(currentPath) && location.pathname == ModalPaths.COMPOSE_TWEET) {
log('navigated back to Compose Tweet editor')
observeDesktopComposeTweetModal($desktopComposeTweetModalPopup)
}
}
currentNotificationCount = notificationCount
currentPath = location.pathname
return
}
currentNotificationCount = notificationCount
currentPath = location.pathname
return
}
// Search terms are shown in the title
@ -6190,7 +6213,11 @@ function shouldHideHomeTimelineItem(type, page) {
return selectedHomeTabIndex >= 2 ? (
settings.listRetweets == 'hide'
) : (
shouldHideSharedTweet(settings.retweets, page) || shouldHideSharedTweet(settings.quoteTweets, page)
page != separatedTweetsTimelineTitle ? (
shouldHideSharedTweet(settings.retweets, page) || shouldHideSharedTweet(settings.quoteTweets, page)
) : (
shouldHideSharedTweet(settings.retweets, page) && shouldHideSharedTweet(settings.quoteTweets, page)
)
)
case 'TWEET':
return page == separatedTweetsTimelineTitle
@ -6227,6 +6254,8 @@ function shouldHideListTimelineItem(type) {
*/
function shouldHideOtherTimelineItem(type) {
switch (type) {
case 'COMMUNITY_TWEET':
case 'PINNED_TWEET':
case 'QUOTE_TWEET':
case 'RETWEET':
case 'RETWEETED_QUOTE_TWEET':
@ -6280,23 +6309,26 @@ async function tweakBookmarksPage() {
}
function tweakCommunitiesPage() {
observeTimeline(currentPage)
observeTimeline(currentPage, {
checkSocialContext: true,
isTabbed: true,
tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`,
})
}
function tweakCommunityPage() {
if (settings.premiumBlueChecks != 'ignore') {
observeTimeline(currentPage, {
classifyTweets: false,
isTabbed: true,
tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`,
onTimelineAppeared() {
// The About tab has static content at the top which can include a check
if (/\/about\/?$/.test(location.pathname)) {
processBlueChecks(document.querySelector(Selectors.PRIMARY_COLUMN))
}
observeTimeline(currentPage, {
checkSocialContext: true,
hideHeadings: false,
isTabbed: true,
tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`,
onTimelineAppeared() {
// The About tab has static content at the top which can include a check
if (/\/about\/?$/.test(location.pathname)) {
processBlueChecks(document.querySelector(Selectors.PRIMARY_COLUMN))
}
})
}
}
})
}
function tweakCommunityMembersPage() {
@ -6304,7 +6336,7 @@ function tweakCommunityMembersPage() {
observeTimeline(currentPage, {
classifyTweets: false,
isTabbed: true,
timelineSelector: 'div[data-testid="primaryColumn"] > div > div:last-child',
timelineSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`,
})
}
}
@ -6368,7 +6400,7 @@ async function tweakExplorePage() {
observeTimeline(currentPage, {
classifyTweets: false,
isTabbed: true,
tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child > div',
tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child > div`,
})
}
return
@ -6538,7 +6570,7 @@ function tweakHomeTimelinePage() {
updateSelectedHomeTabIndex()
wasForYouTabSelected = selectedHomeTabIndex == 0
},
tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child',
tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`,
})
if (desktop) {
@ -6748,12 +6780,10 @@ function tweakNotificationsPage() {
}
}
if (settings.premiumBlueChecks != 'ignore' || settings.restoreLinkHeadlines) {
observeTimeline(currentPage, {
isTabbed: true,
tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child',
})
}
observeTimeline(currentPage, {
isTabbed: true,
tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`,
})
}
async function tweakOwnFocusedTweet($focusedTweet) {
@ -6772,7 +6802,7 @@ async function tweakOwnFocusedTweet($focusedTweet) {
})
if (!$accountAnalyticsUpsell) return
$accountAnalyticsUpsell.classList.add('PremiumUpsell')
$focusedTweet.setAttribute('cpft-analytics-upsell-tagged', 'true')
$focusedTweet.setAttribute('cpft-analytics-upsell-tagged', '')
}
async function tweakProfilePage() {
@ -6789,6 +6819,7 @@ async function tweakProfilePage() {
let tab = currentPath.match(URL_PROFILE_RE)?.[2] || 'tweets'
log(`on ${tab} tab`)
observeTimeline(currentPage, {
checkSocialContext: tab == 'tweets' || tab == 'with_replies',
isUserTimeline: tab == 'affiliates'
})
@ -6905,7 +6936,7 @@ function tweakSearchPage() {
observeTimeline(currentPage, {
hideHeadings: false,
isTabbed: true,
tabbedTimelineContainerSelector: 'div[data-testid="primaryColumn"] > div > div:last-child',
tabbedTimelineContainerSelector: `${Selectors.PRIMARY_COLUMN} > div > div:last-child`,
})
if (desktop) {
@ -6998,7 +7029,7 @@ async function tweakTimelineTabs($timelineTabs) {
* Restores "Tweet" button text.
*/
async function tweakTweetButton() {
let $tweetButton = await getElement(`${desktop ? 'div[data-testid="primaryColumn"]': 'main'} button[data-testid^="tweetButton"]`, {
let $tweetButton = await getElement(`${desktop ? Selectors.PRIMARY_COLUMN: 'main'} button[data-testid^="tweetButton"]`, {
name: 'tweet button',
stopIf: pageIsNot(currentPage),
})

5
types.d.ts vendored
View File

@ -177,6 +177,7 @@ export type QuotedTweet = {
export type SharedTweetsConfig = 'separate' | 'hide' | 'ignore'
export type TweetType =
| 'COMMUNITY_TWEET'
| 'PINNED_TWEET'
| 'PROMOTED_TWEET'
| 'QUOTE_TWEET'
@ -202,6 +203,7 @@ export type TimelineItemType =
| 'UNAVAILABLE'
export type TimelineOptions = {
checkSocialContext?: boolean
classifyTweets?: boolean
hideHeadings?: boolean
isTabbed?: boolean
@ -214,10 +216,9 @@ export type TimelineOptions = {
export type IndividualTweetTimelineOptions = {
observers: Map<string, Disconnectable>
seen: WeakMap<Element, IndividualTweetDetails>
}
export type IndividualTweetDetails = {
export type SeenTweetDetails = {
itemType: TimelineItemType,
hidden: boolean | null,
}