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", "axios": "^1.1.2",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"interweave": "^13.0.0", "interweave": "^13.0.0",
"lodash": "^4.17.21",
"make-plural": "^7.1.0", "make-plural": "^7.1.0",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"react": "^18.2.0", "react": "^18.2.0",
@@ -39,6 +40,7 @@
"devDependencies": { "devDependencies": {
"@lingui/cli": "^3.14.0", "@lingui/cli": "^3.14.0",
"@types/eslint": "^8.4.6", "@types/eslint": "^8.4.6",
"@types/lodash": "^4.14.186",
"@types/mousetrap": "^1.6.9", "@types/mousetrap": "^1.6.9",
"@types/react": "^18.0.21", "@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
@@ -3100,6 +3102,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "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": { "node_modules/@types/mousetrap": {
"version": "1.6.9", "version": "1.6.9",
"resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.9.tgz", "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.9.tgz",
@@ -12465,6 +12473,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "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": { "@types/mousetrap": {
"version": "1.6.9", "version": "1.6.9",
"resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.9.tgz", "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.9.tgz",

View File

@@ -31,6 +31,7 @@
"axios": "^1.1.2", "axios": "^1.1.2",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"interweave": "^13.0.0", "interweave": "^13.0.0",
"lodash": "^4.17.21",
"make-plural": "^7.1.0", "make-plural": "^7.1.0",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"react": "^18.2.0", "react": "^18.2.0",
@@ -46,6 +47,7 @@
"devDependencies": { "devDependencies": {
"@lingui/cli": "^3.14.0", "@lingui/cli": "^3.14.0",
"@types/eslint": "^8.4.6", "@types/eslint": "^8.4.6",
"@types/lodash": "^4.14.186",
"@types/mousetrap": "^1.6.9", "@types/mousetrap": "^1.6.9",
"@types/react": "^18.0.21", "@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",

View File

@@ -3,7 +3,7 @@ import { DEFAULT_THEME } from "@mantine/core"
import { IconType } from "react-icons" import { IconType } from "react-icons"
import { FaAt } from "react-icons/fa" import { FaAt } from "react-icons/fa"
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiTwitter } from "react-icons/si" 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 } = { const categories: { [key: string]: Category } = {
all: { all: {
@@ -90,10 +90,11 @@ export const Constants = {
headerHeight: 60, headerHeight: 60,
sidebarWidth: 350, sidebarWidth: 350,
entryMaxWidth: 650, entryMaxWidth: 650,
isTopVisible: (div: HTMLDivElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight, isTopVisible: (div: HTMLElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight,
isBottomVisible: (div: HTMLDivElement) => div.getBoundingClientRect().bottom <= window.innerHeight, isBottomVisible: (div: HTMLElement) => div.getBoundingClientRect().bottom <= window.innerHeight,
}, },
dom: { dom: {
mainScrollAreaId: "main-scroll-area-id", mainScrollAreaId: "main-scroll-area-id",
entryId: (entry: Entry) => `entry-id-${entry.id}`,
}, },
} }

View File

@@ -77,6 +77,7 @@ describe("entries", () => {
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3" } as Entry], entries: [{ id: "3" } as Entry],
hasMore: true, hasMore: true,
scrollingToEntry: false,
}, },
}, },
}) })
@@ -100,6 +101,7 @@ describe("entries", () => {
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry], entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
hasMore: true, hasMore: true,
scrollingToEntry: false,
}, },
}, },
}) })
@@ -125,6 +127,7 @@ describe("entries", () => {
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry], entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
hasMore: true, hasMore: true,
scrollingToEntry: false,
}, },
}, },
}) })

View File

@@ -3,6 +3,8 @@ import { client } from "app/client"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { RootState } from "app/store" import { RootState } from "app/store"
import { Entries, Entry, MarkRequest } from "app/types" import { Entries, Entry, MarkRequest } from "app/types"
import { scrollToWithCallback } from "app/utils"
import { flushSync } from "react-dom"
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
import { reloadTree } from "./tree" import { reloadTree } from "./tree"
@@ -23,6 +25,7 @@ interface EntriesState {
timestamp?: number timestamp?: number
selectedEntryId?: string selectedEntryId?: string
hasMore: boolean hasMore: boolean
scrollingToEntry: boolean
} }
const initialState: EntriesState = { const initialState: EntriesState = {
@@ -34,6 +37,7 @@ const initialState: EntriesState = {
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [], entries: [],
hasMore: true, hasMore: true,
scrollingToEntry: false,
} }
const getEndpoint = (sourceType: EntrySourceType) => (sourceType === "category" ? client.category.getEntries : client.feed.getEntries) const getEndpoint = (sourceType: EntrySourceType) => (sourceType === "category" ? client.category.getEntries : client.feed.getEntries)
@@ -128,6 +132,7 @@ export const selectEntry = createAsyncThunk<
entry: Entry entry: Entry
expand: boolean expand: boolean
markAsRead: boolean markAsRead: boolean
scrollToEntry: boolean
}, },
{ state: RootState } { state: RootState }
>("entries/entry/select", (arg, thunkApi) => { >("entries/entry/select", (arg, thunkApi) => {
@@ -135,6 +140,9 @@ export const selectEntry = createAsyncThunk<
const entry = state.entries.entries.find(e => e.id === arg.entry.id) const entry = state.entries.entries.find(e => e.id === arg.entry.id)
if (!entry) return if (!entry) return
// 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 // mark as read if requested
if (arg.markAsRead) { if (arg.markAsRead) {
thunkApi.dispatch(markEntry({ entry, read: true })) thunkApi.dispatch(markEntry({ entry, read: true }))
@@ -149,10 +157,47 @@ export const selectEntry = createAsyncThunk<
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry: previouslySelectedEntry, expanded: false })) thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry: previouslySelectedEntry, expanded: false }))
} }
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand })) 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)))
}
}
}) })
export const selectPreviousEntry = createAsyncThunk<void, { expand: boolean; markAsRead: boolean }, { state: RootState }>( const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
"entries/entry/selectPrevious", // the entry is entirely visible, no need to scroll
(arg, thunkApi) => { if (Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)) {
onScrollEnded()
return
}
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 state = thunkApi.getState()
const { entries } = state.entries const { entries } = state.entries
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1 const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
@@ -162,14 +207,20 @@ export const selectPreviousEntry = createAsyncThunk<void, { expand: boolean; mar
entry: entries[previousIndex], entry: entries[previousIndex],
expand: arg.expand, expand: arg.expand,
markAsRead: arg.markAsRead, markAsRead: arg.markAsRead,
scrollToEntry: arg.scrollToEntry,
}) })
) )
} }
} })
) export const selectNextEntry = createAsyncThunk<
export const selectNextEntry = createAsyncThunk<void, { expand: boolean; markAsRead: boolean }, { state: RootState }>( void,
"entries/entry/selectNext", {
(arg, thunkApi) => { expand: boolean
markAsRead: boolean
scrollToEntry: boolean
},
{ state: RootState }
>("entries/entry/selectNext", (arg, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
const { entries } = state.entries const { entries } = state.entries
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1 const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
@@ -179,11 +230,11 @@ export const selectNextEntry = createAsyncThunk<void, { expand: boolean; markAsR
entry: entries[nextIndex], entry: entries[nextIndex],
expand: arg.expand, expand: arg.expand,
markAsRead: arg.markAsRead, markAsRead: arg.markAsRead,
scrollToEntry: arg.scrollToEntry,
}) })
) )
} }
} })
)
export const entriesSlice = createSlice({ export const entriesSlice = createSlice({
name: "entries", name: "entries",
@@ -199,6 +250,9 @@ export const entriesSlice = createSlice({
e.expanded = action.payload.expanded e.expanded = action.payload.expanded
}) })
}, },
setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
state.scrollingToEntry = action.payload
},
}, },
extraReducers: builder => { extraReducers: builder => {
builder.addCase(markEntry.pending, (state, action) => { builder.addCase(markEntry.pending, (state, action) => {

View File

@@ -54,6 +54,11 @@ export const changeShowRead = createAsyncThunk<void, boolean, { state: RootState
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, showRead }) 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 }>( export const changeSharingSetting = createAsyncThunk<void, { site: keyof SharingSettings; value: boolean }, { state: RootState }>(
"settings/sharingSetting", "settings/sharingSetting",
(sharingSetting, thunkApi) => { (sharingSetting, thunkApi) => {
@@ -104,12 +109,22 @@ export const userSlice = createSlice({
if (!state.settings) return if (!state.settings) return
state.settings.showRead = action.meta.arg 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) => { builder.addCase(changeSharingSetting.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
}) })
builder.addMatcher( builder.addMatcher(
isAnyOf(changeLanguage.fulfilled, changeScrollSpeed.fulfilled, changeShowRead.fulfilled, changeSharingSetting.fulfilled), isAnyOf(
changeLanguage.fulfilled,
changeScrollSpeed.fulfilled,
changeShowRead.fulfilled,
changeScrollMarks.fulfilled,
changeSharingSetting.fulfilled
),
() => { () => {
showNotification({ showNotification({
message: t`Settings saved.`, 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 const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
return { width: placeholderWidth, height: placeholderHeight } 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 { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { useMousetrap } from "hooks/useMousetrap" 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 InfiniteScroll from "react-infinite-scroller"
import { FeedEntry } from "./FeedEntry" import { FeedEntry } from "./FeedEntry"
@@ -27,7 +28,8 @@ export function FeedEntries() {
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId) const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
const hasMore = useAppSelector(state => state.entries.hasMore) const hasMore = useAppSelector(state => state.entries.hasMore)
const viewMode = useAppSelector(state => state.user.settings?.viewMode) 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 dispatch = useAppDispatch()
const selectedEntry = entries.find(e => e.id === selectedEntryId) const selectedEntry = entries.find(e => e.id === selectedEntryId)
@@ -46,80 +48,90 @@ export function FeedEntries() {
entry, entry,
expand: !entry.expanded, expand: !entry.expanded,
markAsRead: !entry.expanded, markAsRead: !entry.expanded,
scrollToEntry: true,
}) })
) )
} }
} }
// references to entries html elements
const refs = useRef<{ [id: string]: HTMLDivElement }>({})
useEffect(() => { useEffect(() => {
// remove refs that are not in entries anymore const scrollArea = document.getElementById(Constants.dom.mainScrollAreaId)
Object.keys(refs.current).forEach(k => {
const found = entries.some(e => e.id === k) const listener = () => {
if (!found) delete refs.current[k] if (viewMode !== "expanded") return
if (scrollingToEntry) 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)
}) })
}, [entries]) if (currentEntry) {
dispatch(
// scroll to entry when selected entry changes selectEntry({
useEffect(() => { entry: currentEntry,
if (!selectedEntryId) return expand: false,
if (!selectedEntry?.expanded) return markAsRead: !!scrollMarks,
scrollToEntry: false,
const selectedEntryElement = refs.current[selectedEntryId]
if (Constants.layout.isTopVisible(selectedEntryElement) && Constants.layout.isBottomVisible(selectedEntryElement)) return
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]) )
}
}
const throttledListener = throttle(listener, 100)
scrollArea?.addEventListener("scroll", throttledListener)
return () => scrollArea?.removeEventListener("scroll", throttledListener)
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
useMousetrap("r", () => { useMousetrap("r", () => dispatch(reloadEntries()))
dispatch(reloadEntries()) useMousetrap("j", () =>
})
useMousetrap("j", () => {
dispatch( dispatch(
selectNextEntry({ selectNextEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true,
}) })
) )
}) )
useMousetrap("n", () => { useMousetrap("n", () =>
dispatch( dispatch(
selectNextEntry({ selectNextEntry({
expand: false, expand: false,
markAsRead: false, markAsRead: false,
scrollToEntry: true,
}) })
) )
}) )
useMousetrap("k", () => { useMousetrap("k", () =>
dispatch( dispatch(
selectPreviousEntry({ selectPreviousEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true,
}) })
) )
}) )
useMousetrap("p", () => { useMousetrap("p", () =>
dispatch( dispatch(
selectPreviousEntry({ selectPreviousEntry({
expand: false, expand: false,
markAsRead: false, markAsRead: false,
scrollToEntry: true,
}) })
) )
}) )
useMousetrap("space", () => { useMousetrap("space", () => {
if (selectedEntry) { if (selectedEntry) {
if (selectedEntry.expanded) { if (selectedEntry.expanded) {
const ref = refs.current[selectedEntry.id] const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
if (Constants.layout.isBottomVisible(ref)) { if (entryElement && Constants.layout.isBottomVisible(entryElement)) {
dispatch( dispatch(
selectNextEntry({ selectNextEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true,
}) })
) )
} else { } else {
@@ -135,6 +147,7 @@ export function FeedEntries() {
entry: selectedEntry, entry: selectedEntry,
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true,
}) })
) )
} }
@@ -143,6 +156,7 @@ export function FeedEntries() {
selectNextEntry({ selectNextEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true,
}) })
) )
} }
@@ -150,12 +164,13 @@ export function FeedEntries() {
useMousetrap("shift+space", () => { useMousetrap("shift+space", () => {
if (selectedEntry) { if (selectedEntry) {
if (selectedEntry.expanded) { if (selectedEntry.expanded) {
const ref = refs.current[selectedEntry.id] const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
if (Constants.layout.isTopVisible(ref)) { if (entryElement && Constants.layout.isTopVisible(entryElement)) {
dispatch( dispatch(
selectPreviousEntry({ selectPreviousEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true,
}) })
) )
} else { } else {
@@ -170,6 +185,7 @@ export function FeedEntries() {
selectPreviousEntry({ selectPreviousEntry({
expand: true, expand: true,
markAsRead: true, markAsRead: true,
scrollToEntry: true,
}) })
) )
} }
@@ -183,6 +199,7 @@ export function FeedEntries() {
entry: selectedEntry, entry: selectedEntry,
expand: !selectedEntry.expanded, expand: !selectedEntry.expanded,
markAsRead: !selectedEntry.expanded, markAsRead: !selectedEntry.expanded,
scrollToEntry: true,
}) })
) )
}) })
@@ -222,12 +239,8 @@ export function FeedEntries() {
}) })
) )
}) })
useMousetrap("g a", () => { useMousetrap("g a", () => dispatch(redirectToRootCategory()))
dispatch(redirectToRootCategory()) useMousetrap("?", () => openModal({ title: t`Keyboard shortcuts`, size: "xl", children: <KeyboardShortcutsHelp /> }))
})
useMousetrap("?", () => {
openModal({ title: t`Keyboard shortcuts`, size: "xl", children: <KeyboardShortcutsHelp /> })
})
if (!entries) return <Loader /> if (!entries) return <Loader />
return ( return (
@@ -243,7 +256,7 @@ export function FeedEntries() {
<div <div
key={entry.id} key={entry.id}
ref={el => { ref={el => {
if (el) refs.current[entry.id] = el if (el) el.id = Constants.dom.entryId(entry)
}} }}
> >
<FeedEntry <FeedEntry

View File

@@ -1,7 +1,7 @@
import { t } from "@lingui/macro" import { t } from "@lingui/macro"
import { Divider, Select, SimpleGrid, Stack, Switch } from "@mantine/core" import { Divider, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
import { Constants } from "app/constants" 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 { useAppDispatch, useAppSelector } from "app/store"
import { SharingSettings } from "app/types" import { SharingSettings } from "app/types"
import { locales } from "i18n" import { locales } from "i18n"
@@ -10,6 +10,7 @@ export function DisplaySettings() {
const language = useAppSelector(state => state.user.settings?.language) const language = useAppSelector(state => state.user.settings?.language)
const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed) const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed)
const showRead = useAppSelector(state => state.user.settings?.showRead) 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 sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -37,6 +38,12 @@ export function DisplaySettings() {
onChange={e => dispatch(changeShowRead(e.currentTarget.checked))} 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" /> <Divider label={t`Sharing sites`} labelPosition="center" />
<SimpleGrid cols={2}> <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" msgid "Import"
msgstr "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 #: src/components/content/FeedEntryFooter.tsx
msgid "Keep unread" msgid "Keep unread"
msgstr "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" msgid "Import"
msgstr "Importer" 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 #: src/components/content/FeedEntryFooter.tsx
msgid "Keep unread" msgid "Keep unread"
msgstr "Garder non lu" msgstr "Garder non lu"