diff --git a/commafeed-client/package-lock.json b/commafeed-client/package-lock.json index 8c1cb56e..5cd6f599 100644 --- a/commafeed-client/package-lock.json +++ b/commafeed-client/package-lock.json @@ -24,6 +24,7 @@ "axios": "^1.1.2", "dayjs": "^1.11.5", "interweave": "^13.0.0", + "lodash": "^4.17.21", "make-plural": "^7.1.0", "mousetrap": "^1.6.5", "react": "^18.2.0", @@ -39,6 +40,7 @@ "devDependencies": { "@lingui/cli": "^3.14.0", "@types/eslint": "^8.4.6", + "@types/lodash": "^4.14.186", "@types/mousetrap": "^1.6.9", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", @@ -3100,6 +3102,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.186", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz", + "integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==", + "dev": true + }, "node_modules/@types/mousetrap": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.9.tgz", @@ -12465,6 +12473,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/lodash": { + "version": "4.14.186", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz", + "integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==", + "dev": true + }, "@types/mousetrap": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.9.tgz", diff --git a/commafeed-client/package.json b/commafeed-client/package.json index 54e07272..6062195d 100644 --- a/commafeed-client/package.json +++ b/commafeed-client/package.json @@ -31,6 +31,7 @@ "axios": "^1.1.2", "dayjs": "^1.11.5", "interweave": "^13.0.0", + "lodash": "^4.17.21", "make-plural": "^7.1.0", "mousetrap": "^1.6.5", "react": "^18.2.0", @@ -46,6 +47,7 @@ "devDependencies": { "@lingui/cli": "^3.14.0", "@types/eslint": "^8.4.6", + "@types/lodash": "^4.14.186", "@types/mousetrap": "^1.6.9", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", diff --git a/commafeed-client/src/app/constants.ts b/commafeed-client/src/app/constants.ts index 680c7e80..af1d2652 100644 --- a/commafeed-client/src/app/constants.ts +++ b/commafeed-client/src/app/constants.ts @@ -3,7 +3,7 @@ import { DEFAULT_THEME } from "@mantine/core" import { IconType } from "react-icons" import { FaAt } from "react-icons/fa" import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiTwitter } from "react-icons/si" -import { Category, SharingSettings } from "./types" +import { Category, Entry, SharingSettings } from "./types" const categories: { [key: string]: Category } = { all: { @@ -90,10 +90,11 @@ export const Constants = { headerHeight: 60, sidebarWidth: 350, entryMaxWidth: 650, - isTopVisible: (div: HTMLDivElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight, - isBottomVisible: (div: HTMLDivElement) => div.getBoundingClientRect().bottom <= window.innerHeight, + isTopVisible: (div: HTMLElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight, + isBottomVisible: (div: HTMLElement) => div.getBoundingClientRect().bottom <= window.innerHeight, }, dom: { mainScrollAreaId: "main-scroll-area-id", + entryId: (entry: Entry) => `entry-id-${entry.id}`, }, } diff --git a/commafeed-client/src/app/slices/entries.test.ts b/commafeed-client/src/app/slices/entries.test.ts index b7557078..611a3580 100644 --- a/commafeed-client/src/app/slices/entries.test.ts +++ b/commafeed-client/src/app/slices/entries.test.ts @@ -77,6 +77,7 @@ describe("entries", () => { sourceWebsiteUrl: "", entries: [{ id: "3" } as Entry], hasMore: true, + scrollingToEntry: false, }, }, }) @@ -100,6 +101,7 @@ describe("entries", () => { sourceWebsiteUrl: "", entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry], hasMore: true, + scrollingToEntry: false, }, }, }) @@ -125,6 +127,7 @@ describe("entries", () => { sourceWebsiteUrl: "", entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry], hasMore: true, + scrollingToEntry: false, }, }, }) diff --git a/commafeed-client/src/app/slices/entries.ts b/commafeed-client/src/app/slices/entries.ts index 5e9d9e9b..5345ae3e 100644 --- a/commafeed-client/src/app/slices/entries.ts +++ b/commafeed-client/src/app/slices/entries.ts @@ -3,6 +3,8 @@ import { client } from "app/client" import { Constants } from "app/constants" import { RootState } from "app/store" import { Entries, Entry, MarkRequest } from "app/types" +import { scrollToWithCallback } from "app/utils" +import { flushSync } from "react-dom" // eslint-disable-next-line import/no-cycle import { reloadTree } from "./tree" @@ -23,6 +25,7 @@ interface EntriesState { timestamp?: number selectedEntryId?: string hasMore: boolean + scrollingToEntry: boolean } const initialState: EntriesState = { @@ -34,6 +37,7 @@ const initialState: EntriesState = { sourceWebsiteUrl: "", entries: [], hasMore: true, + scrollingToEntry: false, } const getEndpoint = (sourceType: EntrySourceType) => (sourceType === "category" ? client.category.getEntries : client.feed.getEntries) @@ -128,6 +132,7 @@ export const selectEntry = createAsyncThunk< entry: Entry expand: boolean markAsRead: boolean + scrollToEntry: boolean }, { state: RootState } >("entries/entry/select", (arg, thunkApi) => { @@ -135,55 +140,101 @@ export const selectEntry = createAsyncThunk< const entry = state.entries.entries.find(e => e.id === arg.entry.id) if (!entry) return - // mark as read if requested - if (arg.markAsRead) { - thunkApi.dispatch(markEntry({ entry, read: true })) - } + // flushSync is required because we need the newly selected entry to be expanded + // and the previously selected entry to be collapsed to be able to scroll to the right position + flushSync(() => { + // mark as read if requested + if (arg.markAsRead) { + thunkApi.dispatch(markEntry({ entry, read: true })) + } - // set entry as selected - thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry)) + // set entry as selected + thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry)) - // expand if requested - const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId) - if (previouslySelectedEntry) { - thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry: previouslySelectedEntry, expanded: false })) + // expand if requested + const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId) + if (previouslySelectedEntry) { + thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry: previouslySelectedEntry, expanded: false })) + } + thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand })) + }) + + if (arg.scrollToEntry) { + const entryElement = document.getElementById(Constants.dom.entryId(entry)) + if (entryElement) { + const scrollSpeed = state.user.settings?.scrollSpeed + thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true)) + scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false))) + } } - thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand })) }) -export const selectPreviousEntry = createAsyncThunk( - "entries/entry/selectPrevious", - (arg, thunkApi) => { - const state = thunkApi.getState() - const { entries } = state.entries - const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1 - if (previousIndex >= 0) { - thunkApi.dispatch( - selectEntry({ - entry: entries[previousIndex], - expand: arg.expand, - markAsRead: arg.markAsRead, - }) - ) - } +const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => { + // the entry is entirely visible, no need to scroll + if (Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)) { + onScrollEnded() + return } -) -export const selectNextEntry = createAsyncThunk( - "entries/entry/selectNext", - (arg, thunkApi) => { - const state = thunkApi.getState() - const { entries } = state.entries - const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1 - if (nextIndex < entries.length) { - thunkApi.dispatch( - selectEntry({ - entry: entries[nextIndex], - expand: arg.expand, - markAsRead: arg.markAsRead, - }) - ) - } + + const scrollArea = document.getElementById(Constants.dom.mainScrollAreaId) + if (scrollArea) { + scrollToWithCallback({ + element: scrollArea, + options: { + // add a small gap between the top of the content and the top of the page + top: entryElement.offsetTop - 3, + behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto", + }, + onScrollEnded, + }) } -) +} + +export const selectPreviousEntry = createAsyncThunk< + void, + { + expand: boolean + markAsRead: boolean + scrollToEntry: boolean + }, + { state: RootState } +>("entries/entry/selectPrevious", (arg, thunkApi) => { + const state = thunkApi.getState() + const { entries } = state.entries + const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1 + if (previousIndex >= 0) { + thunkApi.dispatch( + selectEntry({ + entry: entries[previousIndex], + expand: arg.expand, + markAsRead: arg.markAsRead, + scrollToEntry: arg.scrollToEntry, + }) + ) + } +}) +export const selectNextEntry = createAsyncThunk< + void, + { + expand: boolean + markAsRead: boolean + scrollToEntry: boolean + }, + { state: RootState } +>("entries/entry/selectNext", (arg, thunkApi) => { + const state = thunkApi.getState() + const { entries } = state.entries + const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1 + if (nextIndex < entries.length) { + thunkApi.dispatch( + selectEntry({ + entry: entries[nextIndex], + expand: arg.expand, + markAsRead: arg.markAsRead, + scrollToEntry: arg.scrollToEntry, + }) + ) + } +}) export const entriesSlice = createSlice({ name: "entries", @@ -199,6 +250,9 @@ export const entriesSlice = createSlice({ e.expanded = action.payload.expanded }) }, + setScrollingToEntry: (state, action: PayloadAction) => { + state.scrollingToEntry = action.payload + }, }, extraReducers: builder => { builder.addCase(markEntry.pending, (state, action) => { diff --git a/commafeed-client/src/app/slices/user.ts b/commafeed-client/src/app/slices/user.ts index 3e5ada5d..4d69d55c 100644 --- a/commafeed-client/src/app/slices/user.ts +++ b/commafeed-client/src/app/slices/user.ts @@ -54,6 +54,11 @@ export const changeShowRead = createAsyncThunk("settings/scrollMarks", (scrollMarks, thunkApi) => { + const { settings } = thunkApi.getState().user + if (!settings) return + client.user.saveSettings({ ...settings, scrollMarks }) +}) export const changeSharingSetting = createAsyncThunk( "settings/sharingSetting", (sharingSetting, thunkApi) => { @@ -104,12 +109,22 @@ export const userSlice = createSlice({ if (!state.settings) return state.settings.showRead = action.meta.arg }) + builder.addCase(changeScrollMarks.pending, (state, action) => { + if (!state.settings) return + state.settings.scrollMarks = action.meta.arg + }) builder.addCase(changeSharingSetting.pending, (state, action) => { if (!state.settings) return state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value }) builder.addMatcher( - isAnyOf(changeLanguage.fulfilled, changeScrollSpeed.fulfilled, changeShowRead.fulfilled, changeSharingSetting.fulfilled), + isAnyOf( + changeLanguage.fulfilled, + changeScrollSpeed.fulfilled, + changeShowRead.fulfilled, + changeScrollMarks.fulfilled, + changeSharingSetting.fulfilled + ), () => { showNotification({ message: t`Settings saved.`, diff --git a/commafeed-client/src/app/utils.ts b/commafeed-client/src/app/utils.ts index 6a0d32a8..c946cd18 100644 --- a/commafeed-client/src/app/utils.ts +++ b/commafeed-client/src/app/utils.ts @@ -25,3 +25,29 @@ export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height return { width: placeholderWidth, height: placeholderHeight } } + +export const scrollToWithCallback = ({ + element, + options, + onScrollEnded, +}: { + element: HTMLElement + options: ScrollToOptions + onScrollEnded: () => void +}) => { + const offset = (options.top ?? 0).toFixed() + + const onScroll = () => { + if (element.offsetTop.toFixed() === offset) { + element.removeEventListener("scroll", onScroll) + onScrollEnded() + } + } + + element.addEventListener("scroll", onScroll) + + // scrollTo does not trigger if there's nothing to do, trigger it manually + onScroll() + + element.scrollTo(options) +} diff --git a/commafeed-client/src/components/content/FeedEntries.tsx b/commafeed-client/src/components/content/FeedEntries.tsx index 79d5d4ef..cc4d0ec6 100644 --- a/commafeed-client/src/components/content/FeedEntries.tsx +++ b/commafeed-client/src/components/content/FeedEntries.tsx @@ -16,7 +16,8 @@ import { useAppDispatch, useAppSelector } from "app/store" import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp" import { Loader } from "components/Loader" import { useMousetrap } from "hooks/useMousetrap" -import { useEffect, useRef } from "react" +import throttle from "lodash/throttle" +import { useEffect } from "react" import InfiniteScroll from "react-infinite-scroller" import { FeedEntry } from "./FeedEntry" @@ -27,7 +28,8 @@ export function FeedEntries() { const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId) const hasMore = useAppSelector(state => state.entries.hasMore) const viewMode = useAppSelector(state => state.user.settings?.viewMode) - const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed) + const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks) + const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry) const dispatch = useAppDispatch() const selectedEntry = entries.find(e => e.id === selectedEntryId) @@ -46,80 +48,90 @@ export function FeedEntries() { entry, expand: !entry.expanded, markAsRead: !entry.expanded, + scrollToEntry: true, }) ) } } - // references to entries html elements - const refs = useRef<{ [id: string]: HTMLDivElement }>({}) useEffect(() => { - // remove refs that are not in entries anymore - Object.keys(refs.current).forEach(k => { - const found = entries.some(e => e.id === k) - if (!found) delete refs.current[k] - }) - }, [entries]) + const scrollArea = document.getElementById(Constants.dom.mainScrollAreaId) - // scroll to entry when selected entry changes - useEffect(() => { - if (!selectedEntryId) return - if (!selectedEntry?.expanded) return + const listener = () => { + if (viewMode !== "expanded") return + if (scrollingToEntry) return - const selectedEntryElement = refs.current[selectedEntryId] - if (Constants.layout.isTopVisible(selectedEntryElement) && Constants.layout.isBottomVisible(selectedEntryElement)) return + const currentEntry = entries + // use slice to get a copy of the array because reverse mutates the array in-place + .slice() + .reverse() + .find(e => { + const el = document.getElementById(Constants.dom.entryId(e)) + return el && !Constants.layout.isTopVisible(el) + }) + if (currentEntry) { + dispatch( + selectEntry({ + entry: currentEntry, + expand: false, + markAsRead: !!scrollMarks, + scrollToEntry: false, + }) + ) + } + } + const throttledListener = throttle(listener, 100) + scrollArea?.addEventListener("scroll", throttledListener) + return () => scrollArea?.removeEventListener("scroll", throttledListener) + }, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry]) - document.getElementById(Constants.dom.mainScrollAreaId)?.scrollTo({ - // having a small gap between the top of the content and the top of the page is sexier - top: selectedEntryElement.offsetTop - 3, - behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto", - }) - }, [selectedEntryId, selectedEntry?.expanded, scrollSpeed]) - - useMousetrap("r", () => { - dispatch(reloadEntries()) - }) - useMousetrap("j", () => { + useMousetrap("r", () => dispatch(reloadEntries())) + useMousetrap("j", () => dispatch( selectNextEntry({ expand: true, markAsRead: true, + scrollToEntry: true, }) ) - }) - useMousetrap("n", () => { + ) + useMousetrap("n", () => dispatch( selectNextEntry({ expand: false, markAsRead: false, + scrollToEntry: true, }) ) - }) - useMousetrap("k", () => { + ) + useMousetrap("k", () => dispatch( selectPreviousEntry({ expand: true, markAsRead: true, + scrollToEntry: true, }) ) - }) - useMousetrap("p", () => { + ) + useMousetrap("p", () => dispatch( selectPreviousEntry({ expand: false, markAsRead: false, + scrollToEntry: true, }) ) - }) + ) useMousetrap("space", () => { if (selectedEntry) { if (selectedEntry.expanded) { - const ref = refs.current[selectedEntry.id] - if (Constants.layout.isBottomVisible(ref)) { + const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry)) + if (entryElement && Constants.layout.isBottomVisible(entryElement)) { dispatch( selectNextEntry({ expand: true, markAsRead: true, + scrollToEntry: true, }) ) } else { @@ -135,6 +147,7 @@ export function FeedEntries() { entry: selectedEntry, expand: true, markAsRead: true, + scrollToEntry: true, }) ) } @@ -143,6 +156,7 @@ export function FeedEntries() { selectNextEntry({ expand: true, markAsRead: true, + scrollToEntry: true, }) ) } @@ -150,12 +164,13 @@ export function FeedEntries() { useMousetrap("shift+space", () => { if (selectedEntry) { if (selectedEntry.expanded) { - const ref = refs.current[selectedEntry.id] - if (Constants.layout.isTopVisible(ref)) { + const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry)) + if (entryElement && Constants.layout.isTopVisible(entryElement)) { dispatch( selectPreviousEntry({ expand: true, markAsRead: true, + scrollToEntry: true, }) ) } else { @@ -170,6 +185,7 @@ export function FeedEntries() { selectPreviousEntry({ expand: true, markAsRead: true, + scrollToEntry: true, }) ) } @@ -183,6 +199,7 @@ export function FeedEntries() { entry: selectedEntry, expand: !selectedEntry.expanded, markAsRead: !selectedEntry.expanded, + scrollToEntry: true, }) ) }) @@ -222,12 +239,8 @@ export function FeedEntries() { }) ) }) - useMousetrap("g a", () => { - dispatch(redirectToRootCategory()) - }) - useMousetrap("?", () => { - openModal({ title: t`Keyboard shortcuts`, size: "xl", children: }) - }) + useMousetrap("g a", () => dispatch(redirectToRootCategory())) + useMousetrap("?", () => openModal({ title: t`Keyboard shortcuts`, size: "xl", children: })) if (!entries) return return ( @@ -243,7 +256,7 @@ export function FeedEntries() {
{ - if (el) refs.current[entry.id] = el + if (el) el.id = Constants.dom.entryId(entry) }} > state.user.settings?.language) const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed) const showRead = useAppSelector(state => state.user.settings?.showRead) + const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks) const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings) const dispatch = useAppDispatch() @@ -37,6 +38,12 @@ export function DisplaySettings() { onChange={e => dispatch(changeShowRead(e.currentTarget.checked))} /> + dispatch(changeScrollMarks(e.currentTarget.checked))} + /> + diff --git a/commafeed-client/src/locales/en/messages.po b/commafeed-client/src/locales/en/messages.po index 0c27da22..0a3d8272 100644 --- a/commafeed-client/src/locales/en/messages.po +++ b/commafeed-client/src/locales/en/messages.po @@ -333,6 +333,10 @@ msgstr "If you like this project, please consider a donation to support the deve msgid "Import" msgstr "Import" +#: src/components/settings/DisplaySettings.tsx +msgid "In expanded view, scrolling through entries mark them as read" +msgstr "In expanded view, scrolling through entries mark them as read" + #: src/components/content/FeedEntryFooter.tsx msgid "Keep unread" msgstr "Keep unread" diff --git a/commafeed-client/src/locales/fr/messages.po b/commafeed-client/src/locales/fr/messages.po index 7c0e774a..856be309 100644 --- a/commafeed-client/src/locales/fr/messages.po +++ b/commafeed-client/src/locales/fr/messages.po @@ -333,6 +333,10 @@ msgstr "Si vous aimez ce projet, n'hésitez pas à faire un don pour encourager msgid "Import" msgstr "Importer" +#: src/components/settings/DisplaySettings.tsx +msgid "In expanded view, scrolling through entries mark them as read" +msgstr "En mode de lecture étendu, marquer les éléments comme lus lorsque la fenêtre descend." + #: src/components/content/FeedEntryFooter.tsx msgid "Keep unread" msgstr "Garder non lu"