From e54151d2eb0b402486bc088c7bc3a6f8a6787c6e Mon Sep 17 00:00:00 2001 From: Louis POIROT--HATTERMANN Date: Sun, 15 Feb 2026 17:19:43 +0100 Subject: [PATCH] feat: send notification for new entries with Gotify, ntfy or Pushover, configurable per feed. --- commafeed-client/package-lock.json | 2 +- commafeed-client/src/app/tree/tree.test.ts | 1 + commafeed-client/src/app/types.ts | 15 ++ commafeed-client/src/app/user/slice.ts | 11 +- commafeed-client/src/app/user/thunks.ts | 17 +- .../src/components/content/add/Subscribe.tsx | 7 +- .../settings/NotificationSettings.tsx | 133 ++++++++++ .../src/pages/app/FeedDetailsPage.tsx | 42 ++++ .../src/pages/app/SettingsPage.tsx | 10 +- .../backend/feed/FeedRefreshUpdater.java | 55 +++- .../backend/model/FeedSubscription.java | 3 + .../commafeed/backend/model/UserSettings.java | 29 +++ .../commafeed/backend/opml/OPMLImporter.java | 2 +- .../service/FeedSubscriptionService.java | 11 +- .../backend/service/NotificationService.java | 171 +++++++++++++ .../commafeed/frontend/model/Settings.java | 25 ++ .../frontend/model/Subscription.java | 4 + .../request/FeedModificationRequest.java | 3 + .../model/request/SubscribeRequest.java | 3 + .../commafeed/frontend/resource/FeedREST.java | 9 +- .../commafeed/frontend/resource/UserREST.java | 21 ++ .../resources/changelogs/db.changelog-6.1.xml | 27 ++ .../src/main/resources/migrations.xml | 1 + .../backend/feed/FeedRefreshUpdaterTest.java | 165 ++++++++++++ .../backend/opml/OPMLImporterTest.java | 3 +- .../service/NotificationServiceTest.java | 234 ++++++++++++++++++ 26 files changed, 974 insertions(+), 30 deletions(-) create mode 100644 commafeed-client/src/components/settings/NotificationSettings.tsx create mode 100644 commafeed-server/src/main/java/com/commafeed/backend/service/NotificationService.java create mode 100644 commafeed-server/src/main/resources/changelogs/db.changelog-6.1.xml create mode 100644 commafeed-server/src/test/java/com/commafeed/backend/feed/FeedRefreshUpdaterTest.java create mode 100644 commafeed-server/src/test/java/com/commafeed/backend/service/NotificationServiceTest.java diff --git a/commafeed-client/package-lock.json b/commafeed-client/package-lock.json index 38b42d84..fc70167b 100644 --- a/commafeed-client/package-lock.json +++ b/commafeed-client/package-lock.json @@ -6310,7 +6310,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/commafeed-client/src/app/tree/tree.test.ts b/commafeed-client/src/app/tree/tree.test.ts index 7edd32e2..d9697e3f 100644 --- a/commafeed-client/src/app/tree/tree.test.ts +++ b/commafeed-client/src/app/tree/tree.test.ts @@ -27,6 +27,7 @@ const createFeed = (id: number, unread: number): Subscription => ({ feedUrl: "", feedLink: "", iconUrl: "", + notifyOnNewEntries: true, }) const root = createCategory("root") diff --git a/commafeed-client/src/app/types.ts b/commafeed-client/src/app/types.ts index f425a897..3e9f6271 100644 --- a/commafeed-client/src/app/types.ts +++ b/commafeed-client/src/app/types.ts @@ -29,6 +29,7 @@ export interface Subscription { newestItemTime?: number filter?: string filterLegacy?: string + notifyOnNewEntries: boolean } export interface Category { @@ -110,6 +111,7 @@ export interface FeedModificationRequest { categoryId?: string position?: number filter?: string + notifyOnNewEntries?: boolean } export interface GetEntriesRequest { @@ -249,6 +251,17 @@ export interface SharingSettings { buffer: boolean } +export type NotificationService = "disabled" | "ntfy" | "gotify" | "pushover" + +export interface NotificationSettings { + enabled: boolean + type?: Exclude + serverUrl?: string + token?: string + userKey?: string + topic?: string +} + export interface Settings { language?: string readingMode: ReadingMode @@ -271,6 +284,7 @@ export interface Settings { disablePullToRefresh: boolean primaryColor?: string sharingSettings: SharingSettings + notificationSettings: NotificationSettings } export interface LocalSettings { @@ -290,6 +304,7 @@ export interface SubscribeRequest { url: string title: string categoryId?: string + notifyOnNewEntries: boolean } export interface TagRequest { diff --git a/commafeed-client/src/app/user/slice.ts b/commafeed-client/src/app/user/slice.ts index 16911038..d058a2d3 100644 --- a/commafeed-client/src/app/user/slice.ts +++ b/commafeed-client/src/app/user/slice.ts @@ -11,6 +11,7 @@ import { changeMarkAllAsReadConfirmation, changeMarkAllAsReadNavigateToUnread, changeMobileFooter, + changeNotificationSettings, changePrimaryColor, changeReadingMode, changeReadingOrder, @@ -148,6 +149,13 @@ export const userSlice = createSlice({ if (!state.settings) return state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value }) + builder.addCase(changeNotificationSettings.pending, (state, action) => { + if (!state.settings) return + state.settings.notificationSettings = { + ...state.settings.notificationSettings, + ...action.meta.arg, + } + }) builder.addMatcher( isAnyOf( @@ -167,7 +175,8 @@ export const userSlice = createSlice({ changeUnreadCountFavicon.fulfilled, changeDisablePullToRefresh.fulfilled, changePrimaryColor.fulfilled, - changeSharingSetting.fulfilled + changeSharingSetting.fulfilled, + changeNotificationSettings.fulfilled ), () => { showNotification({ diff --git a/commafeed-client/src/app/user/thunks.ts b/commafeed-client/src/app/user/thunks.ts index 0dc030af..5497ec52 100644 --- a/commafeed-client/src/app/user/thunks.ts +++ b/commafeed-client/src/app/user/thunks.ts @@ -1,7 +1,7 @@ import { createAppAsyncThunk } from "@/app/async-thunk" import { client } from "@/app/client" import { reloadEntries } from "@/app/entries/thunks" -import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "@/app/types" +import type { IconDisplayMode, NotificationSettings, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "@/app/types" export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data)) @@ -157,3 +157,18 @@ export const changeSharingSetting = createAppAsyncThunk( }) } ) + +export const changeNotificationSettings = createAppAsyncThunk( + "settings/notificationSettings", + (notificationUpdate: Partial, thunkApi) => { + const { settings } = thunkApi.getState().user + if (!settings) return + client.user.saveSettings({ + ...settings, + notificationSettings: { + ...settings.notificationSettings, + ...notificationUpdate, + }, + }) + } +) diff --git a/commafeed-client/src/components/content/add/Subscribe.tsx b/commafeed-client/src/components/content/add/Subscribe.tsx index 973fa28c..b9fd699a 100644 --- a/commafeed-client/src/components/content/add/Subscribe.tsx +++ b/commafeed-client/src/components/content/add/Subscribe.tsx @@ -1,5 +1,5 @@ import { Trans } from "@lingui/react/macro" -import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core" +import { Box, Button, Checkbox, Group, Stack, Stepper, TextInput } from "@mantine/core" import { useForm } from "@mantine/form" import { useState } from "react" import { useAsyncCallback } from "react-async-hook" @@ -28,6 +28,7 @@ export function Subscribe() { url: "", title: "", categoryId: Constants.categories.all.id, + notifyOnNewEntries: true, }, }) @@ -103,6 +104,10 @@ export function Subscribe() { Feed URL} {...step1Form.getInputProps("url")} disabled /> Feed name} {...step1Form.getInputProps("title")} required autoFocus /> Category} {...step1Form.getInputProps("categoryId")} clearable /> + Receive notifications} + {...step1Form.getInputProps("notifyOnNewEntries", { type: "checkbox" })} + /> diff --git a/commafeed-client/src/components/settings/NotificationSettings.tsx b/commafeed-client/src/components/settings/NotificationSettings.tsx new file mode 100644 index 00000000..474866fe --- /dev/null +++ b/commafeed-client/src/components/settings/NotificationSettings.tsx @@ -0,0 +1,133 @@ +import { Trans } from "@lingui/react/macro" +import { Divider, Select, Stack, TextInput } from "@mantine/core" +import { useEffect, useState } from "react" +import { useAppDispatch, useAppSelector } from "@/app/store" +import type { NotificationService as NotificationServiceType, NotificationSettings as NotificationSettingsType } from "@/app/types" +import { changeNotificationSettings } from "@/app/user/thunks" + +function useDebouncedSave(value: string, settingsKey: string, dispatch: ReturnType) { + const [localValue, setLocalValue] = useState(value) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + const onBlur = async () => { + if (localValue !== value) { + await dispatch(changeNotificationSettings({ [settingsKey]: localValue })) + } + } + + return { localValue, setLocalValue, onBlur } +} + +function toServiceValue(settings?: NotificationSettingsType): NotificationServiceType { + if (settings?.enabled && settings.type) { + return settings.type + } + return "disabled" +} + +export function NotificationSettings() { + const notificationSettings = useAppSelector(state => state.user.settings?.notificationSettings) + const dispatch = useAppDispatch() + + const serviceValue = toServiceValue(notificationSettings) + + const serverUrl = useDebouncedSave(notificationSettings?.serverUrl ?? "", "serverUrl", dispatch) + const token = useDebouncedSave(notificationSettings?.token ?? "", "token", dispatch) + const userKey = useDebouncedSave(notificationSettings?.userKey ?? "", "userKey", dispatch) + const topic = useDebouncedSave(notificationSettings?.topic ?? "", "topic", dispatch) + + const onServiceChange = async (value: string | null) => { + if (value === "disabled" || !value) { + await dispatch(changeNotificationSettings({ enabled: false, type: undefined })) + } else { + await dispatch(changeNotificationSettings({ enabled: true, type: value as Exclude })) + } + } + + return ( + + + Notifications + + } + /> + +