feat: send notification for new entries with Gotify, ntfy or Pushover, configurable per feed.

This commit is contained in:
Louis POIROT--HATTERMANN
2026-02-15 17:19:43 +01:00
parent ca2c687f26
commit e54151d2eb
26 changed files with 974 additions and 30 deletions

View File

@@ -27,6 +27,7 @@ const createFeed = (id: number, unread: number): Subscription => ({
feedUrl: "",
feedLink: "",
iconUrl: "",
notifyOnNewEntries: true,
})
const root = createCategory("root")

View File

@@ -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<NotificationService, "disabled">
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 {

View File

@@ -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({

View File

@@ -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<NotificationSettings>, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({
...settings,
notificationSettings: {
...settings.notificationSettings,
...notificationUpdate,
},
})
}
)

View File

@@ -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() {
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
<Checkbox
label={<Trans>Receive notifications</Trans>}
{...step1Form.getInputProps("notifyOnNewEntries", { type: "checkbox" })}
/>
</Stack>
</Stepper.Step>
</Stepper>

View File

@@ -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<typeof useAppDispatch>) {
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<NotificationServiceType, "disabled"> }))
}
}
return (
<Stack>
<Divider
label={
<Trans>
<b>Notifications</b>
</Trans>
}
/>
<Select
label={<Trans>Notification service</Trans>}
data={[
{ value: "disabled", label: "Disabled" },
{ value: "ntfy", label: "ntfy" },
{ value: "gotify", label: "Gotify" },
{ value: "pushover", label: "Pushover" },
]}
value={serviceValue}
onChange={onServiceChange}
/>
{serviceValue === "ntfy" && (
<>
<TextInput
label={<Trans>Server URL</Trans>}
placeholder="https://ntfy.sh"
value={serverUrl.localValue}
onChange={e => serverUrl.setLocalValue(e.currentTarget.value)}
onBlur={serverUrl.onBlur}
/>
<TextInput
label={<Trans>Topic</Trans>}
placeholder="commafeed"
value={topic.localValue}
onChange={e => topic.setLocalValue(e.currentTarget.value)}
onBlur={topic.onBlur}
/>
<TextInput
label={<Trans>Access token (optional)</Trans>}
value={token.localValue}
onChange={e => token.setLocalValue(e.currentTarget.value)}
onBlur={token.onBlur}
/>
</>
)}
{serviceValue === "gotify" && (
<>
<TextInput
label={<Trans>Server URL</Trans>}
placeholder="https://gotify.example.com"
value={serverUrl.localValue}
onChange={e => serverUrl.setLocalValue(e.currentTarget.value)}
onBlur={serverUrl.onBlur}
/>
<TextInput
label={<Trans>App token</Trans>}
value={token.localValue}
onChange={e => token.setLocalValue(e.currentTarget.value)}
onBlur={token.onBlur}
/>
</>
)}
{serviceValue === "pushover" && (
<>
<TextInput
label={<Trans>API token</Trans>}
value={token.localValue}
onChange={e => token.setLocalValue(e.currentTarget.value)}
onBlur={token.onBlur}
/>
<TextInput
label={<Trans>User key</Trans>}
value={userKey.localValue}
onChange={e => userKey.setLocalValue(e.currentTarget.value)}
onBlur={userKey.onBlur}
/>
</>
)}
</Stack>
)
}

View File

@@ -3,6 +3,7 @@ import {
Anchor,
Box,
Button,
Checkbox,
Code,
Container,
Divider,
@@ -32,6 +33,38 @@ import { FilteringExpressionEditor } from "@/components/content/edit/FilteringEx
import { Loader } from "@/components/Loader"
import { RelativeDate } from "@/components/RelativeDate"
function FilteringExpressionDescription() {
const example = <Code>url.contains('youtube') or (author eq 'athou' and title.contains('github'))</Code>
return (
<div>
<div>
<Trans>
If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read
automatically.
</Trans>
</div>
<div>
<Trans>
Available variables are 'title', 'content', 'url' 'author' and 'categories' and their content is converted to lower case
to ease string comparison.
</Trans>
</div>
<div>
<Trans>Example: {example}.</Trans>
</div>
<div>
<Trans>
<span>Complete syntax is available </span>
<a href="https://commons.apache.org/proper/commons-jexl/reference/syntax.html" target="_blank" rel="noreferrer">
here
</a>
<span>.</span>
</Trans>
</div>
</div>
)
}
export function FeedDetailsPage() {
const { id } = useParams()
if (!id) throw new Error("id required")
@@ -163,6 +196,15 @@ export function FeedDetailsPage() {
<FilteringExpressionEditor initialValue={feed.filter} onChange={value => form.setFieldValue("filter", value)} />
</Box>
</Input.Wrapper>
<TextInput
label={<Trans>Filtering expression</Trans>}
description={<FilteringExpressionDescription />}
{...form.getInputProps("filter")}
/>
<Checkbox
label={<Trans>Receive notifications</Trans>}
{...form.getInputProps("notifyOnNewEntries", { type: "checkbox" })}
/>
<Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>

View File

@@ -1,8 +1,9 @@
import { Trans } from "@lingui/react/macro"
import { Container, Tabs } from "@mantine/core"
import { TbCode, TbPhoto, TbUser } from "react-icons/tb"
import { TbBell, TbCode, TbPhoto, TbUser } from "react-icons/tb"
import { CustomCodeSettings } from "@/components/settings/CustomCodeSettings"
import { DisplaySettings } from "@/components/settings/DisplaySettings"
import { NotificationSettings } from "@/components/settings/NotificationSettings"
import { ProfileSettings } from "@/components/settings/ProfileSettings"
export function SettingsPage() {
@@ -13,6 +14,9 @@ export function SettingsPage() {
<Tabs.Tab value="display" leftSection={<TbPhoto size={16} />}>
<Trans>Display</Trans>
</Tabs.Tab>
<Tabs.Tab value="notifications" leftSection={<TbBell size={16} />}>
<Trans>Notifications</Trans>
</Tabs.Tab>
<Tabs.Tab value="customCode" leftSection={<TbCode size={16} />}>
<Trans>Custom code</Trans>
</Tabs.Tab>
@@ -25,6 +29,10 @@ export function SettingsPage() {
<DisplaySettings />
</Tabs.Panel>
<Tabs.Panel value="notifications" pt="xl">
<NotificationSettings />
</Tabs.Panel>
<Tabs.Panel value="customCode" pt="xl">
<CustomCodeSettings />
</Tabs.Panel>