forked from Archives/Athou_commafeed
feat: send notification for new entries with Gotify, ntfy or Pushover, configurable per feed.
This commit is contained in:
@@ -27,6 +27,7 @@ const createFeed = (id: number, unread: number): Subscription => ({
|
||||
feedUrl: "",
|
||||
feedLink: "",
|
||||
iconUrl: "",
|
||||
notifyOnNewEntries: true,
|
||||
})
|
||||
|
||||
const root = createCategory("root")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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())}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user