diff --git a/commafeed-client/src/app/constants.ts b/commafeed-client/src/app/constants.ts index 1785c4ea..e0c4b4b7 100644 --- a/commafeed-client/src/app/constants.ts +++ b/commafeed-client/src/app/constants.ts @@ -18,6 +18,13 @@ const categories: Record> = { 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", diff --git a/commafeed-client/src/app/types.ts b/commafeed-client/src/app/types.ts index 29d02165..68a71379 100644 --- a/commafeed-client/src/app/types.ts +++ b/commafeed-client/src/app/types.ts @@ -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 diff --git a/commafeed-client/src/app/user/slice.ts b/commafeed-client/src/app/user/slice.ts index 9f090547..7ee9ef1b 100644 --- a/commafeed-client/src/app/user/slice.ts +++ b/commafeed-client/src/app/user/slice.ts @@ -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 diff --git a/commafeed-client/src/app/user/thunks.ts b/commafeed-client/src/app/user/thunks.ts index aa473410..d55449eb 100644 --- a/commafeed-client/src/app/user/thunks.ts +++ b/commafeed-client/src/app/user/thunks.ts @@ -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) => { diff --git a/commafeed-client/src/app/utils.ts b/commafeed-client/src/app/utils.ts index 8e5cd3a3..c8bd012e 100644 --- a/commafeed-client/src/app/utils.ts +++ b/commafeed-client/src/app/utils.ts @@ -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) } diff --git a/commafeed-client/src/components/settings/DisplaySettings.tsx b/commafeed-client/src/components/settings/DisplaySettings.tsx index e9d42219..7b0b5651 100644 --- a/commafeed-client/src/components/settings/DisplaySettings.tsx +++ b/commafeed-client/src/components/settings/DisplaySettings.tsx @@ -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))} /> + Infrequent posts threshold (days)} + description={Feeds posting less often than this (on average) will appear in the Infrequent view} + min={1} + value={infrequentThresholdDays} + onChange={async value => await dispatch(changeInfrequentThresholdDays(+value))} + /> + Scrolling} labelPosition="center" /> const starredIcon = +const infrequentIcon = const tagIcon = const expandedIcon = const collapsedIcon = @@ -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 = () => ( + Infrequent} + 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() { {allCategoryNode()} {starredCategoryNode()} + {infrequentCategoryNode()} {root.children.map(c => recursiveCategoryNode(c))} {root.feeds.map(f => feedNode(f))} {tags?.map(tag => tagNode(tag))} diff --git a/commafeed-client/src/locales/en/messages.po b/commafeed-client/src/locales/en/messages.po index f12a88b3..0adbb721 100644 --- a/commafeed-client/src/locales/en/messages.po +++ b/commafeed-client/src/locales/en/messages.po @@ -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" diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/Feed.java b/commafeed-server/src/main/java/com/commafeed/backend/model/Feed.java index 288cf0cb..a24d5628 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/Feed.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/Feed.java @@ -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; diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/UserSettings.java b/commafeed-server/src/main/java/com/commafeed/backend/model/UserSettings.java index f0f7628f..e1fa5a02 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/UserSettings.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/UserSettings.java @@ -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; diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/Settings.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/Settings.java index 8fe6500c..2f4ee7d6 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/Settings.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/Settings.java @@ -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; diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/Subscription.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/Subscription.java index 534bbe8f..a6499607 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/Subscription.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/Subscription.java @@ -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; } diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/CategoryREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/CategoryREST.java index c8592628..162e340e 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/CategoryREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/CategoryREST.java @@ -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 subs = feedSubscriptionDAO.findAll(user); removeExcludedSubscriptions(subs, excludedIds); + if (INFREQUENT.equals(id)) { + entries.setName("Infrequent"); + removeFrequentSubscriptions(subs, user); + } List list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate, offset, limit + 1, order, true, tag, null, null); @@ -244,9 +252,12 @@ public class CategoryREST { List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); User user = authenticationContext.getCurrentUser(); - if (ALL.equals(req.getId())) { + if (ALL.equals(req.getId()) || INFREQUENT.equals(req.getId())) { List 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 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 subs, List excludedIds) { if (CollectionUtils.isNotEmpty(excludedIds)) { subs.removeIf(sub -> excludedIds.contains(sub.getId())); diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java index 7bdd070c..7cab26d2 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java @@ -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()); diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-7.1.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-7.1.xml new file mode 100644 index 00000000..52eccabd --- /dev/null +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-7.1.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/migrations.xml b/commafeed-server/src/main/resources/migrations.xml index 85438d80..eab37a0f 100644 --- a/commafeed-server/src/main/resources/migrations.xml +++ b/commafeed-server/src/main/resources/migrations.xml @@ -38,5 +38,6 @@ + \ No newline at end of file