diff --git a/README.md b/README.md index 930aa098..902dcb44 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc - REST API - Fever-compatible API for native mobile apps - Can automatically mark articles as read based on user-defined rules +- Push notifications when new articles are published - Highly customizable with [custom CSS](https://athou.github.io/commafeed/documentation/custom-css) and JavaScript - [Browser extension](https://github.com/Athou/commafeed-browser-extension) - Compiles to native code for blazing fast startup and low memory usage diff --git a/commafeed-client/package-lock.json b/commafeed-client/package-lock.json index 527a51f3..73196820 100644 --- a/commafeed-client/package-lock.json +++ b/commafeed-client/package-lock.json @@ -6298,7 +6298,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..1a3620bc 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: "", + pushNotificationsEnabled: true, }) const root = createCategory("root") diff --git a/commafeed-client/src/app/types.ts b/commafeed-client/src/app/types.ts index f425a897..dba79a7d 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 + pushNotificationsEnabled: boolean } export interface Category { @@ -110,6 +111,7 @@ export interface FeedModificationRequest { categoryId?: string position?: number filter?: string + pushNotificationsEnabled: boolean } export interface GetEntriesRequest { @@ -236,6 +238,7 @@ export interface ServerInfo { forceRefreshCooldownDuration: number initialSetupRequired: boolean minimumPasswordLength: number + pushNotificationsEnabled: boolean } export interface SharingSettings { @@ -249,6 +252,16 @@ export interface SharingSettings { buffer: boolean } +export type PushNotificationType = "ntfy" | "gotify" | "pushover" + +export interface PushNotificationSettings { + type?: PushNotificationType + serverUrl?: string + userId?: string + userSecret?: string + topic?: string +} + export interface Settings { language?: string readingMode: ReadingMode @@ -271,6 +284,7 @@ export interface Settings { disablePullToRefresh: boolean primaryColor?: string sharingSettings: SharingSettings + pushNotificationSettings: PushNotificationSettings } export interface LocalSettings { @@ -290,6 +304,7 @@ export interface SubscribeRequest { url: string title: string categoryId?: string + pushNotificationsEnabled: boolean } export interface TagRequest { diff --git a/commafeed-client/src/app/user/slice.ts b/commafeed-client/src/app/user/slice.ts index 16911038..e093817e 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,10 @@ 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.pushNotificationSettings = action.meta.arg + }) builder.addMatcher( isAnyOf( @@ -167,7 +172,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..2d18e9f4 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, PushNotificationSettings, 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,15 @@ export const changeSharingSetting = createAppAsyncThunk( }) } ) + +export const changeNotificationSettings = createAppAsyncThunk( + "settings/notificationSettings", + (pushNotificationSettings: PushNotificationSettings, thunkApi) => { + const { settings } = thunkApi.getState().user + if (!settings) return + client.user.saveSettings({ + ...settings, + pushNotificationSettings, + }) + } +) diff --git a/commafeed-client/src/components/ReceivePushNotificationsChechbox.tsx b/commafeed-client/src/components/ReceivePushNotificationsChechbox.tsx new file mode 100644 index 00000000..62908d83 --- /dev/null +++ b/commafeed-client/src/components/ReceivePushNotificationsChechbox.tsx @@ -0,0 +1,19 @@ +import { Trans } from "@lingui/react/macro" +import { Checkbox, type CheckboxProps } from "@mantine/core" +import type { ReactNode } from "react" +import { useAppSelector } from "@/app/store" + +export const ReceivePushNotificationsChechbox = (props: CheckboxProps) => { + const pushNotificationsEnabled = useAppSelector(state => state.server.serverInfos?.pushNotificationsEnabled) + const pushNotificationsConfigured = useAppSelector(state => !!state.user.settings?.pushNotificationSettings.type) + + const disabled = !pushNotificationsEnabled || !pushNotificationsConfigured + let description: ReactNode = "" + if (!pushNotificationsEnabled) { + description = Push notifications are not enabled on this CommaFeed instance. + } else if (!pushNotificationsConfigured) { + description = Push notifications are not configured in your user settings. + } + + return Receive push notifications} disabled={disabled} description={description} {...props} /> +} diff --git a/commafeed-client/src/components/content/add/Subscribe.tsx b/commafeed-client/src/components/content/add/Subscribe.tsx index 973fa28c..86c87d41 100644 --- a/commafeed-client/src/components/content/add/Subscribe.tsx +++ b/commafeed-client/src/components/content/add/Subscribe.tsx @@ -11,6 +11,7 @@ import { useAppDispatch } from "@/app/store" import { reloadTree } from "@/app/tree/thunks" import type { FeedInfoRequest, SubscribeRequest } from "@/app/types" import { Alert } from "@/components/Alert" +import { ReceivePushNotificationsChechbox } from "@/components/ReceivePushNotificationsChechbox" import { CategorySelect } from "./CategorySelect" export function Subscribe() { @@ -28,6 +29,7 @@ export function Subscribe() { url: "", title: "", categoryId: Constants.categories.all.id, + pushNotificationsEnabled: false, }, }) @@ -103,6 +105,9 @@ export function Subscribe() { Feed URL} {...step1Form.getInputProps("url")} disabled /> Feed name} {...step1Form.getInputProps("title")} required autoFocus /> Category} {...step1Form.getInputProps("categoryId")} clearable /> + diff --git a/commafeed-client/src/components/settings/PushNotificationSettings.tsx b/commafeed-client/src/components/settings/PushNotificationSettings.tsx new file mode 100644 index 00000000..76ce7570 --- /dev/null +++ b/commafeed-client/src/components/settings/PushNotificationSettings.tsx @@ -0,0 +1,93 @@ +import { useLingui } from "@lingui/react" +import { Trans } from "@lingui/react/macro" +import { Button, Group, Select, Stack, TextInput } from "@mantine/core" +import { useForm } from "@mantine/form" +import { useEffect } from "react" +import { TbDeviceFloppy } from "react-icons/tb" +import { redirectToSelectedSource } from "@/app/redirect/thunks" +import { useAppDispatch, useAppSelector } from "@/app/store" +import type { PushNotificationSettings as PushNotificationSettingsModel } from "@/app/types" +import { changeNotificationSettings } from "@/app/user/thunks" + +export function PushNotificationSettings() { + const notificationSettings = useAppSelector(state => state.user.settings?.pushNotificationSettings) + const pushNotificationsEnabled = useAppSelector(state => state.server.serverInfos?.pushNotificationsEnabled) + const { _ } = useLingui() + const dispatch = useAppDispatch() + + const form = useForm() + useEffect(() => { + if (notificationSettings) form.initialize(notificationSettings) + }, [form.initialize, notificationSettings]) + + const handleSubmit = (values: PushNotificationSettingsModel) => { + dispatch(changeNotificationSettings(values)) + } + + const typeInputProps = form.getInputProps("type") + + if (!pushNotificationsEnabled) { + return Push notifications are not enabled on this CommaFeed instance. + } + return ( +
+ +