Compare commits

...

5 Commits

19 changed files with 226 additions and 6 deletions

22
README-fork.md Normal file
View File

@@ -0,0 +1,22 @@
# `garrettmills/commafeed`
This is my personal fork of `Athou/commafeed` with some tweaks:
- "Infrequent" tab - like "All" but limits to blogs w/ an average post interval greater than a user-configurable number of days
- User preference to disable the swipe-to-open-menu gesture on mobile
## Building
Use `gmfork-build-docker.sh` to build the JVM Docker image for `linux/amd64`:
You can use the `DB_VARIANT` env var to change which DB the image builds with. By default, it builds the `postgresql` variant.
```sh
DOCKER_REGISTRY=myregistry.example.com DB_VARIANT=h2 ./gmfork-build-docker.sh
```
To run locally:
```sh
docker run -p 8082:8082 $DOCKER_REGISTRY/commafeed-fork:latest
```

View File

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

View File

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

View File

@@ -4,9 +4,11 @@ import { createSlice, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"
import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types" import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types"
import { import {
changeCustomContextMenu, changeCustomContextMenu,
changeDisableMobileSwipe,
changeDisablePullToRefresh, changeDisablePullToRefresh,
changeEntriesToKeepOnTopWhenScrolling, changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode, changeExternalLinkIconDisplayMode,
changeInfrequentThresholdDays,
changeLanguage, changeLanguage,
changeMarkAllAsReadConfirmation, changeMarkAllAsReadConfirmation,
changeMarkAllAsReadNavigateToUnread, changeMarkAllAsReadNavigateToUnread,
@@ -141,6 +143,14 @@ export const userSlice = createSlice({
if (!state.settings) return if (!state.settings) return
state.settings.disablePullToRefresh = action.meta.arg state.settings.disablePullToRefresh = action.meta.arg
}) })
builder.addCase(changeDisableMobileSwipe.pending, (state, action) => {
if (!state.settings) return
state.settings.disableMobileSwipe = 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) => { builder.addCase(changePrimaryColor.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.primaryColor = action.meta.arg state.settings.primaryColor = action.meta.arg
@@ -171,6 +181,8 @@ export const userSlice = createSlice({
changeUnreadCountTitle.fulfilled, changeUnreadCountTitle.fulfilled,
changeUnreadCountFavicon.fulfilled, changeUnreadCountFavicon.fulfilled,
changeDisablePullToRefresh.fulfilled, changeDisablePullToRefresh.fulfilled,
changeDisableMobileSwipe.fulfilled,
changeInfrequentThresholdDays.fulfilled,
changePrimaryColor.fulfilled, changePrimaryColor.fulfilled,
changeSharingSetting.fulfilled, changeSharingSetting.fulfilled,
changePushNotificationSettings.fulfilled changePushNotificationSettings.fulfilled

View File

@@ -131,6 +131,12 @@ export const changeDisablePullToRefresh = createAppAsyncThunk(
} }
) )
export const changeDisableMobileSwipe = createAppAsyncThunk("settings/disableMobileSwipe", (disableMobileSwipe: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, disableMobileSwipe })
})
export const changePrimaryColor = createAppAsyncThunk("settings/primaryColor", (primaryColor: string, thunkApi) => { export const changePrimaryColor = createAppAsyncThunk("settings/primaryColor", (primaryColor: string, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
@@ -158,6 +164,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( export const changePushNotificationSettings = createAppAsyncThunk(
"settings/pushNotificationSettings", "settings/pushNotificationSettings",
(pushNotificationSettings: PushNotificationSettings, thunkApi) => { (pushNotificationSettings: PushNotificationSettings, thunkApi) => {

View File

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

View File

@@ -9,9 +9,11 @@ import { useAppDispatch, useAppSelector } from "@/app/store"
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types" import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
import { import {
changeCustomContextMenu, changeCustomContextMenu,
changeDisableMobileSwipe,
changeDisablePullToRefresh, changeDisablePullToRefresh,
changeEntriesToKeepOnTopWhenScrolling, changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode, changeExternalLinkIconDisplayMode,
changeInfrequentThresholdDays,
changeLanguage, changeLanguage,
changeMarkAllAsReadConfirmation, changeMarkAllAsReadConfirmation,
changeMarkAllAsReadNavigateToUnread, changeMarkAllAsReadNavigateToUnread,
@@ -44,6 +46,8 @@ export function DisplaySettings() {
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle) const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon) const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh) const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
const disableMobileSwipe = useAppSelector(state => state.user.settings?.disableMobileSwipe)
const infrequentThresholdDays = useAppSelector(state => state.user.settings?.infrequentThresholdDays)
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings) const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
const { _ } = useLingui() const { _ } = useLingui()
@@ -143,6 +147,20 @@ export function DisplaySettings() {
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))} onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
/> />
<Switch
label={<Trans>On mobile, disable swipe gesture to open the menu</Trans>}
checked={disableMobileSwipe}
onChange={async e => await dispatch(changeDisableMobileSwipe(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" /> <Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
<Switch <Switch

View File

@@ -1,7 +1,7 @@
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { Box, Stack } from "@mantine/core" import { Box, Stack } from "@mantine/core"
import React from "react" 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 { Constants } from "@/app/constants"
import { import {
redirectToCategory, redirectToCategory,
@@ -23,6 +23,7 @@ import { TreeSearch } from "./TreeSearch"
const allIcon = <TbInbox size={16} /> const allIcon = <TbInbox size={16} />
const starredIcon = <TbStar size={16} /> const starredIcon = <TbStar size={16} />
const infrequentIcon = <TbClock size={16} />
const tagIcon = <TbTag size={16} /> const tagIcon = <TbTag size={16} />
const expandedIcon = <TbChevronDown size={16} /> const expandedIcon = <TbChevronDown size={16} />
const collapsedIcon = <TbChevronRight size={16} /> const collapsedIcon = <TbChevronRight size={16} />
@@ -34,6 +35,10 @@ export function Tree() {
const source = useAppSelector(state => state.entries.source) const source = useAppSelector(state => state.entries.source)
const tags = useAppSelector(state => state.user.tags) const tags = useAppSelector(state => state.user.tags)
const showRead = useAppSelector(state => state.user.settings?.showRead) 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 dispatch = useAppDispatch()
const isFeedDisplayed = (feed: Subscription) => { const isFeedDisplayed = (feed: Subscription) => {
@@ -115,6 +120,22 @@ export function Tree() {
onClick={categoryClicked} 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) => { const categoryNode = (category: Category, level = 0) => {
if (!isCategoryDisplayed(category)) return null if (!isCategoryDisplayed(category)) return null
@@ -197,6 +218,7 @@ export function Tree() {
<Box className="cf-tree"> <Box className="cf-tree">
{allCategoryNode()} {allCategoryNode()}
{starredCategoryNode()} {starredCategoryNode()}
{infrequentCategoryNode()}
{root.children.map(c => recursiveCategoryNode(c))} {root.children.map(c => recursiveCategoryNode(c))}
{root.feeds.map(f => feedNode(f))} {root.feeds.map(f => feedNode(f))}
{tags?.map(tag => tagNode(tag))} {tags?.map(tag => tagNode(tag))}

View File

@@ -405,6 +405,10 @@ msgstr "Feed name"
msgid "Feed URL" msgid "Feed URL"
msgstr "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 #: src/components/header/ProfileMenu.tsx
msgid "Fetch all my feeds now" msgid "Fetch all my feeds now"
msgstr "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" msgid "Indigo"
msgstr "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 #: src/pages/auth/InitialSetupPage.tsx
msgid "Initial Setup" msgid "Initial Setup"
msgstr "Initial Setup" msgstr "Initial Setup"
@@ -703,6 +715,10 @@ msgstr "On desktop"
msgid "On mobile" msgid "On mobile"
msgstr "On mobile" msgstr "On mobile"
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile, disable swipe gesture to open the menu"
msgstr "On mobile, disable swipe gesture to open the menu"
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "On mobile, show action buttons at the bottom of the screen" msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "On mobile, show action buttons at the bottom of the screen" msgstr "On mobile, show action buttons at the bottom of the screen"

View File

@@ -79,6 +79,7 @@ export default function Layout(props: Readonly<LayoutProps>) {
const webSocketConnected = useAppSelector(state => state.server.webSocketConnected) const webSocketConnected = useAppSelector(state => state.server.webSocketConnected)
const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval) const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval)
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter) const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
const disableMobileSwipe = useAppSelector(state => state.user.settings?.disableMobileSwipe)
const sidebarWidth = useAppSelector(state => state.user.localSettings.sidebarWidth) const sidebarWidth = useAppSelector(state => state.user.localSettings.sidebarWidth)
const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -164,6 +165,9 @@ export default function Layout(props: Readonly<LayoutProps>) {
const swipeHandlers = useSwipeable({ const swipeHandlers = useSwipeable({
onSwiping: e => { onSwiping: e => {
if (disableMobileSwipe) {
return
}
const threshold = document.documentElement.clientWidth / 6 const threshold = document.documentElement.clientWidth / 6
if (e.absX > threshold) { if (e.absX > threshold) {
dispatch(setMobileMenuOpen(e.dir === "Right")) dispatch(setMobileMenuOpen(e.dir === "Right"))

View File

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

View File

@@ -145,6 +145,9 @@ public class UserSettings extends AbstractModel {
private boolean unreadCountTitle; private boolean unreadCountTitle;
private boolean unreadCountFavicon; private boolean unreadCountFavicon;
private boolean disablePullToRefresh; private boolean disablePullToRefresh;
private boolean disableMobileSwipe;
private int infrequentThresholdDays;
private boolean email; private boolean email;
private boolean gmail; private boolean gmail;

View File

@@ -76,6 +76,12 @@ public class Settings implements Serializable {
@Schema(description = "disable pull to refresh", required = true) @Schema(description = "disable pull to refresh", required = true)
private boolean disablePullToRefresh; private boolean disablePullToRefresh;
@Schema(description = "disable swipe gesture to open mobile menu", required = true)
private boolean disableMobileSwipe;
@Schema(description = "threshold in days for the infrequent view", required = true)
private int infrequentThresholdDays;
@Schema(description = "primary theme color to use in the UI") @Schema(description = "primary theme color to use in the UI")
private String primaryColor; 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)") @Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays; 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) { public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
FeedCategory category = subscription.getCategory(); FeedCategory category = subscription.getCategory();
Feed feed = subscription.getFeed(); Feed feed = subscription.getFeed();
@@ -93,6 +96,7 @@ public class Subscription implements Serializable {
sub.setFilterLegacy(subscription.getFilterLegacy()); sub.setFilterLegacy(subscription.getFilterLegacy());
sub.setPushNotificationsEnabled(subscription.isPushNotificationsEnabled()); sub.setPushNotificationsEnabled(subscription.isPushNotificationsEnabled());
sub.setAutoMarkAsReadAfterDays(subscription.getAutoMarkAsReadAfterDays()); sub.setAutoMarkAsReadAfterDays(subscription.getAutoMarkAsReadAfterDays());
sub.setAverageEntryIntervalMs(feed.getAverageEntryInterval());
return sub; return sub;
} }

View File

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

View File

@@ -132,7 +132,9 @@ public class UserREST {
s.setUnreadCountTitle(settings.isUnreadCountTitle()); s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon()); s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh()); s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setDisableMobileSwipe(settings.isDisableMobileSwipe());
s.setPrimaryColor(settings.getPrimaryColor()); s.setPrimaryColor(settings.getPrimaryColor());
s.setInfrequentThresholdDays(settings.getInfrequentThresholdDays());
if (settings.getPushNotifications() != null) { if (settings.getPushNotifications() != null) {
s.getPushNotificationSettings().setType(settings.getPushNotifications().getType()); s.getPushNotificationSettings().setType(settings.getPushNotifications().getType());
@@ -168,6 +170,8 @@ public class UserREST {
s.setUnreadCountTitle(false); s.setUnreadCountTitle(false);
s.setUnreadCountFavicon(true); s.setUnreadCountFavicon(true);
s.setDisablePullToRefresh(false); s.setDisablePullToRefresh(false);
s.setDisableMobileSwipe(false);
s.setInfrequentThresholdDays(7);
} }
return s; return s;
} }
@@ -204,7 +208,9 @@ public class UserREST {
s.setUnreadCountTitle(settings.isUnreadCountTitle()); s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon()); s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh()); s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setDisableMobileSwipe(settings.isDisableMobileSwipe());
s.setPrimaryColor(settings.getPrimaryColor()); s.setPrimaryColor(settings.getPrimaryColor());
s.setInfrequentThresholdDays(settings.getInfrequentThresholdDays());
PushNotificationUserSettings ps = new PushNotificationUserSettings(); PushNotificationUserSettings ps = new PushNotificationUserSettings();
ps.setType(settings.getPushNotificationSettings().getType()); ps.setType(settings.getPushNotificationSettings().getType());

View File

@@ -0,0 +1,22 @@
<?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>
<changeSet id="add-disable-mobile-swipe" author="athou">
<addColumn tableName="USERSETTINGS">
<column name="disableMobileSwipe" type="BOOLEAN" valueBoolean="false">
<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.11.xml" />
<include file="changelogs/db.changelog-5.12.xml" /> <include file="changelogs/db.changelog-5.12.xml" />
<include file="changelogs/db.changelog-7.0.xml" /> <include file="changelogs/db.changelog-7.0.xml" />
<include file="changelogs/db.changelog-gmfork.xml" />
</databaseChangeLog> </databaseChangeLog>

34
gmfork-build-docker.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
DB_VARIANT="${DB_VARIANT:-postgresql}"
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
ARTIFACTS_DIR="$REPO_ROOT/artifacts"
if [ -z "${DOCKER_REGISTRY:-}" ]; then
echo "Error: DOCKER_REGISTRY is not set" >&2
exit 1
fi
# Build
cd "$REPO_ROOT"
./mvnw --batch-mode --no-transfer-progress install -P${DB_VARIANT} -DskipTests
# Prepare artifacts
rm -rf "$ARTIFACTS_DIR"
mkdir -p "$ARTIFACTS_DIR"
cp commafeed-server/target/commafeed-*-${DB_VARIANT}-jvm.zip "$ARTIFACTS_DIR/"
unzip -q "$ARTIFACTS_DIR"/*-${DB_VARIANT}-jvm.zip -d "$ARTIFACTS_DIR/extracted-jvm-package"
mv "$ARTIFACTS_DIR/extracted-jvm-package"/commafeed-* "$ARTIFACTS_DIR/extracted-jvm-package/quarkus-app"
# Build image
docker build \
--platform linux/amd64 \
--file commafeed-server/src/main/docker/Dockerfile.jvm \
--tag "$DOCKER_REGISTRY/commafeed-fork:latest" \
.
rm -rf "$ARTIFACTS_DIR"
echo "Built: $DOCKER_REGISTRY/commafeed-fork:latest"