Add Infrequent tab and corresponding user setting

This commit is contained in:
2026-03-17 21:38:17 -05:00
parent d05c5b9d7f
commit e2a1630adc
16 changed files with 126 additions and 6 deletions

View File

@@ -18,6 +18,13 @@ const categories: Record<string, Omit<Category, "name">> = {
feeds: [],
position: 1,
},
infrequent: {
id: "infrequent",
expanded: false,
children: [],
feeds: [],
position: 2,
},
}
const sharing: {
@@ -105,6 +112,7 @@ export const Constants = {
tooltip: {
delay: 500,
},
infrequentThresholdDaysDefault: 7,
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
customCssDocumentationUrl: "https://athou.github.io/commafeed/documentation/custom-css",
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",

View File

@@ -31,6 +31,7 @@ export interface Subscription {
filterLegacy?: string
pushNotificationsEnabled: boolean
autoMarkAsReadAfterDays?: number
averageEntryIntervalMs?: number
}
export interface Category {
@@ -284,6 +285,7 @@ export interface Settings {
unreadCountTitle: boolean
unreadCountFavicon: boolean
disablePullToRefresh: boolean
infrequentThresholdDays: number
primaryColor?: string
sharingSettings: SharingSettings
pushNotificationSettings: PushNotificationSettings

View File

@@ -7,6 +7,7 @@ import {
changeDisablePullToRefresh,
changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode,
changeInfrequentThresholdDays,
changeLanguage,
changeMarkAllAsReadConfirmation,
changeMarkAllAsReadNavigateToUnread,
@@ -141,6 +142,10 @@ export const userSlice = createSlice({
if (!state.settings) return
state.settings.disablePullToRefresh = action.meta.arg
})
builder.addCase(changeInfrequentThresholdDays.pending, (state, action) => {
if (!state.settings) return
state.settings.infrequentThresholdDays = action.meta.arg
})
builder.addCase(changePrimaryColor.pending, (state, action) => {
if (!state.settings) return
state.settings.primaryColor = action.meta.arg
@@ -171,6 +176,7 @@ export const userSlice = createSlice({
changeUnreadCountTitle.fulfilled,
changeUnreadCountFavicon.fulfilled,
changeDisablePullToRefresh.fulfilled,
changeInfrequentThresholdDays.fulfilled,
changePrimaryColor.fulfilled,
changeSharingSetting.fulfilled,
changePushNotificationSettings.fulfilled

View File

@@ -158,6 +158,15 @@ export const changeSharingSetting = createAppAsyncThunk(
}
)
export const changeInfrequentThresholdDays = createAppAsyncThunk(
"settings/infrequentThresholdDays",
(infrequentThresholdDays: number, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, infrequentThresholdDays })
}
)
export const changePushNotificationSettings = createAppAsyncThunk(
"settings/pushNotificationSettings",
(pushNotificationSettings: PushNotificationSettings, thunkApi) => {

View File

@@ -26,20 +26,22 @@ export function flattenCategoryTree(category: TreeCategory): TreeCategory[] {
return categories
}
export function categoryUnreadCount(category?: TreeCategory): number {
export function categoryUnreadCount(category?: TreeCategory, maxFrequencyThresholdMs?: number): number {
if (!category) return 0
return flattenCategoryTree(category)
.flatMap(c => c.feeds)
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
.map(f => f.unread)
.reduce((total, current) => total + current, 0)
}
export function categoryHasNewEntries(category?: TreeCategory): boolean {
export function categoryHasNewEntries(category?: TreeCategory, maxFrequencyThresholdMs?: number): boolean {
if (!category) return false
return flattenCategoryTree(category)
.flatMap(c => c.feeds)
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
.some(f => f.hasNewEntries)
}

View File

@@ -12,6 +12,7 @@ import {
changeDisablePullToRefresh,
changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode,
changeInfrequentThresholdDays,
changeLanguage,
changeMarkAllAsReadConfirmation,
changeMarkAllAsReadNavigateToUnread,
@@ -44,6 +45,7 @@ export function DisplaySettings() {
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
const infrequentThresholdDays = useAppSelector(state => state.user.settings?.infrequentThresholdDays)
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
const { _ } = useLingui()
@@ -143,6 +145,14 @@ export function DisplaySettings() {
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
/>
<NumberInput
label={<Trans>Infrequent posts threshold (days)</Trans>}
description={<Trans>Feeds posting less often than this (on average) will appear in the Infrequent view</Trans>}
min={1}
value={infrequentThresholdDays}
onChange={async value => await dispatch(changeInfrequentThresholdDays(+value))}
/>
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
<Switch

View File

@@ -1,7 +1,7 @@
import { Trans } from "@lingui/react/macro"
import { Box, Stack } from "@mantine/core"
import React from "react"
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
import { TbChevronDown, TbChevronRight, TbClock, TbInbox, TbStar, TbTag } from "react-icons/tb"
import { Constants } from "@/app/constants"
import {
redirectToCategory,
@@ -23,6 +23,7 @@ import { TreeSearch } from "./TreeSearch"
const allIcon = <TbInbox size={16} />
const starredIcon = <TbStar size={16} />
const infrequentIcon = <TbClock size={16} />
const tagIcon = <TbTag size={16} />
const expandedIcon = <TbChevronDown size={16} />
const collapsedIcon = <TbChevronRight size={16} />
@@ -34,6 +35,10 @@ export function Tree() {
const source = useAppSelector(state => state.entries.source)
const tags = useAppSelector(state => state.user.tags)
const showRead = useAppSelector(state => state.user.settings?.showRead)
const infrequentThresholdDays = useAppSelector(
state => state.user.settings?.infrequentThresholdDays ?? Constants.infrequentThresholdDaysDefault
)
const infrequentThresholdMs = infrequentThresholdDays * 24 * 3600 * 1000
const dispatch = useAppDispatch()
const isFeedDisplayed = (feed: Subscription) => {
@@ -115,6 +120,22 @@ export function Tree() {
onClick={categoryClicked}
/>
)
const infrequentCategoryNode = () => (
<TreeNode
id={Constants.categories.infrequent.id}
type="category"
name={<Trans>Infrequent</Trans>}
icon={infrequentIcon}
unread={categoryUnreadCount(root, infrequentThresholdMs)}
hasNewEntries={categoryHasNewEntries(root, infrequentThresholdMs)}
selected={source.type === "category" && source.id === Constants.categories.infrequent.id}
expanded={false}
level={0}
hasError={false}
hasWarning={false}
onClick={categoryClicked}
/>
)
const categoryNode = (category: Category, level = 0) => {
if (!isCategoryDisplayed(category)) return null
@@ -197,6 +218,7 @@ export function Tree() {
<Box className="cf-tree">
{allCategoryNode()}
{starredCategoryNode()}
{infrequentCategoryNode()}
{root.children.map(c => recursiveCategoryNode(c))}
{root.feeds.map(f => feedNode(f))}
{tags?.map(tag => tagNode(tag))}

View File

@@ -405,6 +405,10 @@ msgstr "Feed name"
msgid "Feed URL"
msgstr "Feed URL"
#: src/components/settings/DisplaySettings.tsx
msgid "Feeds posting less often than this (on average) will appear in the Infrequent view"
msgstr "Feeds posting less often than this (on average) will appear in the Infrequent view"
#: src/components/header/ProfileMenu.tsx
msgid "Fetch all my feeds now"
msgstr "Fetch all my feeds now"
@@ -502,6 +506,14 @@ msgstr "In expanded view, scrolling through entries mark them as read"
msgid "Indigo"
msgstr "Indigo"
#: src/components/sidebar/Tree.tsx
msgid "Infrequent"
msgstr "Infrequent"
#: src/components/settings/DisplaySettings.tsx
msgid "Infrequent posts threshold (days)"
msgstr "Infrequent posts threshold (days)"
#: src/pages/auth/InitialSetupPage.tsx
msgid "Initial Setup"
msgstr "Initial Setup"

View File

@@ -98,7 +98,7 @@ public class Feed extends AbstractModel {
private String etagHeader;
/**
* average time between entries in the feed
* average time between entries in the feed in milliseconds
*/
private Long averageEntryInterval;

View File

@@ -146,6 +146,8 @@ public class UserSettings extends AbstractModel {
private boolean unreadCountFavicon;
private boolean disablePullToRefresh;
private int infrequentThresholdDays;
private boolean email;
private boolean gmail;
private boolean facebook;

View File

@@ -76,6 +76,9 @@ public class Settings implements Serializable {
@Schema(description = "disable pull to refresh", required = true)
private boolean disablePullToRefresh;
@Schema(description = "threshold in days for the infrequent view", required = true)
private int infrequentThresholdDays;
@Schema(description = "primary theme color to use in the UI")
private String primaryColor;

View File

@@ -71,6 +71,9 @@ public class Subscription implements Serializable {
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
@Schema(description = "average time in milliseconds between entries in this feed, null if unknown")
private Long averageEntryIntervalMs;
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
FeedCategory category = subscription.getCategory();
Feed feed = subscription.getFeed();
@@ -93,6 +96,7 @@ public class Subscription implements Serializable {
sub.setFilterLegacy(subscription.getFilterLegacy());
sub.setPushNotificationsEnabled(subscription.isPushNotificationsEnabled());
sub.setAutoMarkAsReadAfterDays(subscription.getAutoMarkAsReadAfterDays());
sub.setAverageEntryIntervalMs(feed.getAverageEntryInterval());
return sub;
}

View File

@@ -40,12 +40,14 @@ import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
@@ -83,6 +85,7 @@ public class CategoryREST {
public static final String ALL = "all";
public static final String STARRED = "starred";
public static final String INFREQUENT = "infrequent";
private final AuthenticationContext authenticationContext;
private final FeedCategoryDAO feedCategoryDAO;
@@ -90,6 +93,7 @@ public class CategoryREST {
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryService feedEntryService;
private final FeedSubscriptionService feedSubscriptionService;
private final UserSettingsDAO userSettingsDAO;
private final CommaFeedConfiguration config;
private final UriInfo uri;
@@ -139,11 +143,15 @@ public class CategoryREST {
}
User user = authenticationContext.getCurrentUser();
if (ALL.equals(id)) {
if (ALL.equals(id) || INFREQUENT.equals(id)) {
entries.setName(Optional.ofNullable(tag).orElse("All"));
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, excludedIds);
if (INFREQUENT.equals(id)) {
entries.setName("Infrequent");
removeFrequentSubscriptions(subs, user);
}
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
offset, limit + 1, order, true, tag, null, null);
@@ -244,9 +252,12 @@ public class CategoryREST {
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
User user = authenticationContext.getCurrentUser();
if (ALL.equals(req.getId())) {
if (ALL.equals(req.getId()) || INFREQUENT.equals(req.getId())) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
if (INFREQUENT.equals(req.getId())) {
removeFrequentSubscriptions(subs, user);
}
feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
} else if (STARRED.equals(req.getId())) {
feedEntryService.markStarredEntries(user, olderThan, insertedBefore);
@@ -260,6 +271,17 @@ public class CategoryREST {
return Response.ok().build();
}
private void removeFrequentSubscriptions(List<FeedSubscription> subs, User user) {
UserSettings userSettings = userSettingsDAO.findByUser(user);
int infrequentDays = userSettings != null && userSettings.getInfrequentThresholdDays() > 0
? userSettings.getInfrequentThresholdDays()
: 7;
long infrequentThresholdMs = (long) infrequentDays * 24 * 3600 * 1000;
subs.removeIf(
sub -> sub.getFeed().getAverageEntryInterval() == null || sub.getFeed().getAverageEntryInterval() < infrequentThresholdMs);
}
private void removeExcludedSubscriptions(List<FeedSubscription> subs, List<Long> excludedIds) {
if (CollectionUtils.isNotEmpty(excludedIds)) {
subs.removeIf(sub -> excludedIds.contains(sub.getId()));

View File

@@ -133,6 +133,7 @@ public class UserREST {
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setPrimaryColor(settings.getPrimaryColor());
s.setInfrequentThresholdDays(settings.getInfrequentThresholdDays());
if (settings.getPushNotifications() != null) {
s.getPushNotificationSettings().setType(settings.getPushNotifications().getType());
@@ -168,6 +169,7 @@ public class UserREST {
s.setUnreadCountTitle(false);
s.setUnreadCountFavicon(true);
s.setDisablePullToRefresh(false);
s.setInfrequentThresholdDays(7);
}
return s;
}
@@ -205,6 +207,7 @@ public class UserREST {
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setPrimaryColor(settings.getPrimaryColor());
s.setInfrequentThresholdDays(settings.getInfrequentThresholdDays());
PushNotificationUserSettings ps = new PushNotificationUserSettings();
ps.setType(settings.getPushNotificationSettings().getType());

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="add-infrequent-days-threshold" author="athou">
<addColumn tableName="USERSETTINGS">
<column name="infrequentThresholdDays" type="INT" valueNumeric="7">
<constraints nullable="false" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>

View File

@@ -38,5 +38,6 @@
<include file="changelogs/db.changelog-5.11.xml" />
<include file="changelogs/db.changelog-5.12.xml" />
<include file="changelogs/db.changelog-7.0.xml" />
<include file="changelogs/db.changelog-7.1.xml" />
</databaseChangeLog>