diff --git a/src/features/scroll_to_bottom/feature.json b/src/features/scroll_to_bottom/feature.json index aaf7f404ab..59a35088cd 100644 --- a/src/features/scroll_to_bottom/feature.json +++ b/src/features/scroll_to_bottom/feature.json @@ -6,5 +6,12 @@ "class_name": "ri-download-fill", "color": "#e8d738", "background_color": "black" + }, + "preferences": { + "stopAtCaughtUp": { + "type": "checkbox", + "label": "Stop scrolling the Following feed when I reach the Changes/Trending/etc. carousel", + "default": true + } } } diff --git a/src/features/scroll_to_bottom/index.js b/src/features/scroll_to_bottom/index.js index 58b38f64b1..c0a527a333 100644 --- a/src/features/scroll_to_bottom/index.js +++ b/src/features/scroll_to_bottom/index.js @@ -1,7 +1,10 @@ import { keyToClasses, keyToCss } from '../../utils/css_map.js'; +import { debounce } from '../../utils/debounce.js'; import { buildStyle, displayBlockUnlessDisabledAttr } from '../../utils/interface.js'; import { translate } from '../../utils/language_data.js'; import { pageModifications } from '../../utils/mutations.js'; +import { getPreferences } from '../../utils/preferences.js'; +import { cellItem } from '../../utils/react_props.js'; const scrollToBottomButtonId = 'xkit-scroll-to-bottom-button'; $(`[id="${scrollToBottomButtonId}"]`).remove(); @@ -13,6 +16,7 @@ ${keyToCss('notifications')} + div `; const knightRiderLoaderSelector = `:is(${loaderSelector}) > ${keyToCss('knightRiderLoader')}`; +let stopAtCaughtUp; let scrollToBottomButton; let active = false; @@ -42,7 +46,9 @@ const scrollToBottom = () => { }; const observer = new ResizeObserver(scrollToBottom); -const startScrolling = () => { +const startScrolling = async () => { + if (stopAtCaughtUp && await scrollToCaughtUp()) return; + observer.observe(document.documentElement); active = true; scrollToBottomButton.classList.add(activeClass); @@ -89,15 +95,56 @@ const addButtonToPage = async function ([scrollToTopButton]) { pageModifications.register('*', checkForButtonRemoved); }; +const reliablyScrollToTarget = target => { + const callback = () => { + window.scrollBy({ top: target?.getBoundingClientRect?.()?.top }); + debouncedDisconnect(); + }; + const observer = new ResizeObserver(callback); + const debouncedDisconnect = debounce(() => observer.disconnect(), 500); + observer.observe(document.documentElement); + callback(); +}; + +const caughtUpCarouselObjectType = 'followed_tag_carousel_card'; + +const scrollToCaughtUp = async (addedCells) => { + for (const cell of addedCells || [...document.querySelectorAll(keyToCss('cell'))]) { + const item = await cellItem(cell); + if (item.elements?.some(({ objectType }) => objectType === caughtUpCarouselObjectType)) { + const titleElement = cell?.previousElementSibling; + if (!titleElement) continue; + + if (active) { + stopScrolling(); + } else { + const titleElementTop = titleElement?.getBoundingClientRect?.()?.top; + const isAboveViewportBottom = titleElementTop !== undefined && titleElementTop < window.innerHeight; + if (isAboveViewportBottom) continue; + } + + reliablyScrollToTarget(titleElement); + return true; + } + } +}; + +const onCellsAdded = addedCells => active && scrollToCaughtUp(addedCells); + export const main = async function () { + ({ stopAtCaughtUp } = await getPreferences('scroll_to_bottom')); + pageModifications.register(`button[aria-label="${translate('Scroll to top')}"]`, addButtonToPage); pageModifications.register(knightRiderLoaderSelector, onLoadersAdded); + stopAtCaughtUp && pageModifications.register(keyToCss(('cell')), onCellsAdded); }; export const clean = async function () { pageModifications.unregister(addButtonToPage); pageModifications.unregister(checkForButtonRemoved); pageModifications.unregister(onLoadersAdded); + pageModifications.unregister(onCellsAdded); + stopScrolling(); scrollToBottomButton?.remove(); }; diff --git a/src/main_world/unbury_cell_item.js b/src/main_world/unbury_cell_item.js new file mode 100644 index 0000000000..1652a465a3 --- /dev/null +++ b/src/main_world/unbury_cell_item.js @@ -0,0 +1,14 @@ +export default function unburyCellItem () { + const cellElement = this; + const reactKey = Object.keys(cellElement).find(key => key.startsWith('__reactFiber')); + let fiber = cellElement[reactKey]; + + while (fiber !== null) { + const { item } = fiber.memoizedProps || {}; + if (item !== undefined) { + return item; + } else { + fiber = fiber.return; + } + } +} diff --git a/src/utils/debounce.js b/src/utils/debounce.js new file mode 100644 index 0000000000..7b268af262 --- /dev/null +++ b/src/utils/debounce.js @@ -0,0 +1,7 @@ +export const debounce = (func, ms) => { + let timeoutID; + return (...args) => { + clearTimeout(timeoutID); + timeoutID = setTimeout(() => func(...args), ms); + }; +}; diff --git a/src/utils/react_props.js b/src/utils/react_props.js index a612790f22..3971ae6984 100644 --- a/src/utils/react_props.js +++ b/src/utils/react_props.js @@ -12,6 +12,15 @@ export const timelineObject = postElement => { return postElement.timelineObjectPromise; }; +/** + * @param {Element} cellElement An on-screen timeline cell + * @returns {Promise} The element's buried item property + */ +export const cellItem = cellElement => { + cellElement.cellItemPromise ??= inject('/main_world/unbury_cell_item.js', [], cellElement); + return cellElement.cellItemPromise; +}; + /** * @param {Element} trailItemElement An on-screen reblog trail item element * @returns {Promise} The trail item element's trailItem context value