select and mark entry as read when scrolling in expanded view

This commit is contained in:
Athou
2022-10-13 11:27:04 +02:00
parent 6f49f1fe01
commit d7c6f8eb52
11 changed files with 236 additions and 93 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}`,
},
}

View File

@@ -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,
},
},
})

View File

@@ -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<void, { expand: boolean; markAsRead: 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,
})
)
}
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<void, { expand: boolean; markAsRead: 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,
})
)
}
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<boolean>) => {
state.scrollingToEntry = action.payload
},
},
extraReducers: builder => {
builder.addCase(markEntry.pending, (state, action) => {

View File

@@ -54,6 +54,11 @@ export const changeShowRead = createAsyncThunk<void, boolean, { state: RootState
if (!settings) return
client.user.saveSettings({ ...settings, showRead })
})
export const changeScrollMarks = createAsyncThunk<void, boolean, { state: RootState }>("settings/scrollMarks", (scrollMarks, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, scrollMarks })
})
export const changeSharingSetting = createAsyncThunk<void, { site: keyof SharingSettings; value: boolean }, { state: RootState }>(
"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.`,

View File

@@ -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)
}

View File

@@ -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: <KeyboardShortcutsHelp /> })
})
useMousetrap("g a", () => dispatch(redirectToRootCategory()))
useMousetrap("?", () => openModal({ title: t`Keyboard shortcuts`, size: "xl", children: <KeyboardShortcutsHelp /> }))
if (!entries) return <Loader />
return (
@@ -243,7 +256,7 @@ export function FeedEntries() {
<div
key={entry.id}
ref={el => {
if (el) refs.current[entry.id] = el
if (el) el.id = Constants.dom.entryId(entry)
}}
>
<FeedEntry

View File

@@ -1,7 +1,7 @@
import { t } from "@lingui/macro"
import { Divider, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
import { Constants } from "app/constants"
import { changeLanguage, changeScrollSpeed, changeSharingSetting, changeShowRead } from "app/slices/user"
import { changeLanguage, changeScrollMarks, changeScrollSpeed, changeSharingSetting, changeShowRead } from "app/slices/user"
import { useAppDispatch, useAppSelector } from "app/store"
import { SharingSettings } from "app/types"
import { locales } from "i18n"
@@ -10,6 +10,7 @@ export function DisplaySettings() {
const language = useAppSelector(state => 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))}
/>
<Switch
label={t`In expanded view, scrolling through entries mark them as read`}
checked={scrollMarks}
onChange={e => dispatch(changeScrollMarks(e.currentTarget.checked))}
/>
<Divider label={t`Sharing sites`} labelPosition="center" />
<SimpleGrid cols={2}>

View File

@@ -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"

View File

@@ -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"