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: [],
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,8 @@ export interface Settings {
unreadCountTitle: boolean
unreadCountFavicon: boolean
disablePullToRefresh: boolean
disableMobileSwipe: boolean
infrequentThresholdDays: number
primaryColor?: string
sharingSettings: SharingSettings
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 {
changeCustomContextMenu,
changeDisableMobileSwipe,
changeDisablePullToRefresh,
changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode,
changeInfrequentThresholdDays,
changeLanguage,
changeMarkAllAsReadConfirmation,
changeMarkAllAsReadNavigateToUnread,
@@ -141,6 +143,14 @@ export const userSlice = createSlice({
if (!state.settings) return
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) => {
if (!state.settings) return
state.settings.primaryColor = action.meta.arg
@@ -171,6 +181,8 @@ export const userSlice = createSlice({
changeUnreadCountTitle.fulfilled,
changeUnreadCountFavicon.fulfilled,
changeDisablePullToRefresh.fulfilled,
changeDisableMobileSwipe.fulfilled,
changeInfrequentThresholdDays.fulfilled,
changePrimaryColor.fulfilled,
changeSharingSetting.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) => {
const { settings } = thunkApi.getState().user
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(
"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

@@ -9,9 +9,11 @@ import { useAppDispatch, useAppSelector } from "@/app/store"
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
import {
changeCustomContextMenu,
changeDisableMobileSwipe,
changeDisablePullToRefresh,
changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode,
changeInfrequentThresholdDays,
changeLanguage,
changeMarkAllAsReadConfirmation,
changeMarkAllAsReadNavigateToUnread,
@@ -44,6 +46,8 @@ 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 disableMobileSwipe = useAppSelector(state => state.user.settings?.disableMobileSwipe)
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 +147,20 @@ export function DisplaySettings() {
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" />
<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"
@@ -703,6 +715,10 @@ msgstr "On desktop"
msgid "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
msgid "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 treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval)
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 headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter
const dispatch = useAppDispatch()
@@ -164,6 +165,9 @@ export default function Layout(props: Readonly<LayoutProps>) {
const swipeHandlers = useSwipeable({
onSwiping: e => {
if (disableMobileSwipe) {
return
}
const threshold = document.documentElement.clientWidth / 6
if (e.absX > threshold) {
dispatch(setMobileMenuOpen(e.dir === "Right"))

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

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

View File

@@ -76,6 +76,12 @@ public class Settings implements Serializable {
@Schema(description = "disable pull to refresh", required = true)
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")
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

@@ -132,7 +132,9 @@ public class UserREST {
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setDisableMobileSwipe(settings.isDisableMobileSwipe());
s.setPrimaryColor(settings.getPrimaryColor());
s.setInfrequentThresholdDays(settings.getInfrequentThresholdDays());
if (settings.getPushNotifications() != null) {
s.getPushNotificationSettings().setType(settings.getPushNotifications().getType());
@@ -168,6 +170,8 @@ public class UserREST {
s.setUnreadCountTitle(false);
s.setUnreadCountFavicon(true);
s.setDisablePullToRefresh(false);
s.setDisableMobileSwipe(false);
s.setInfrequentThresholdDays(7);
}
return s;
}
@@ -204,7 +208,9 @@ public class UserREST {
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setDisableMobileSwipe(settings.isDisableMobileSwipe());
s.setPrimaryColor(settings.getPrimaryColor());
s.setInfrequentThresholdDays(settings.getInfrequentThresholdDays());
PushNotificationUserSettings ps = new PushNotificationUserSettings();
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.12.xml" />
<include file="changelogs/db.changelog-7.0.xml" />
<include file="changelogs/db.changelog-gmfork.xml" />
</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"