mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Update Notification
Added update notification Added notification settings
This commit is contained in:
parent
0938a3c592
commit
981824a814
14 changed files with 648 additions and 46 deletions
|
|
@ -138,7 +138,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = UserSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "me":
|
||||
if self.action in ["me", "preferences"]:
|
||||
return [Authenticated()]
|
||||
|
||||
return [IsAdmin()]
|
||||
|
|
@ -176,6 +176,28 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
serializer = UserSerializer(user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="get",
|
||||
operation_description="Get current user's preferences (custom_properties)",
|
||||
)
|
||||
@swagger_auto_schema(
|
||||
method="patch",
|
||||
operation_description="Update current user's preferences (custom_properties)",
|
||||
)
|
||||
@action(detail=False, methods=["get", "patch"], url_path="me/preferences")
|
||||
def preferences(self, request):
|
||||
user = request.user
|
||||
if request.method == "GET":
|
||||
return Response(user.custom_properties or {})
|
||||
|
||||
data = request.data or {}
|
||||
# Merge incoming data into existing custom_properties (shallow merge)
|
||||
props = user.custom_properties or {}
|
||||
props.update(data)
|
||||
user.custom_properties = props
|
||||
user.save(update_fields=["custom_properties"])
|
||||
return Response(user.custom_properties)
|
||||
|
||||
|
||||
# 🔹 3) Group Management APIs
|
||||
class GroupViewSet(viewsets.ModelViewSet):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@
|
|||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version, rehash_streams_endpoint
|
||||
from .api_views import (
|
||||
UserAgentViewSet,
|
||||
StreamProfileViewSet,
|
||||
CoreSettingsViewSet,
|
||||
environment,
|
||||
version,
|
||||
rehash_streams_endpoint,
|
||||
latest_release,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'useragents', UserAgentViewSet, basename='useragent')
|
||||
|
|
@ -11,6 +19,7 @@ router.register(r'settings', CoreSettingsViewSet, basename='coresettings')
|
|||
urlpatterns = [
|
||||
path('settings/env/', environment, name='token_refresh'),
|
||||
path('version/', version, name='version'),
|
||||
path('latest-release/', latest_release, name='latest_release'),
|
||||
path('rehash-streams/', rehash_streams_endpoint, name='rehash_streams'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -282,6 +282,75 @@ def version(request):
|
|||
)
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="get",
|
||||
operation_description="Fetch latest GitHub release info for Dispatcharr",
|
||||
responses={200: "Latest release information"},
|
||||
)
|
||||
@api_view(["GET"])
|
||||
def latest_release(request):
|
||||
"""Return information about the latest GitHub release and whether it's newer than current."""
|
||||
from version import __version__ as current_version
|
||||
|
||||
api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest"
|
||||
try:
|
||||
r = requests.get(
|
||||
api_url,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "Dispatcharr",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch latest release: {e}")
|
||||
return Response(
|
||||
{
|
||||
"current_version": current_version,
|
||||
"latest_version": current_version,
|
||||
"latest_url": None,
|
||||
"is_newer": False,
|
||||
"error": True,
|
||||
"message": "Unable to check GitHub releases",
|
||||
}
|
||||
)
|
||||
|
||||
tag = data.get("tag_name") or data.get("name") or ""
|
||||
latest_version = str(tag).lstrip("vV").strip() or current_version
|
||||
|
||||
def parse_ver(v: str):
|
||||
parts = []
|
||||
for p in v.split("."):
|
||||
try:
|
||||
parts.append(int(p))
|
||||
except Exception:
|
||||
# Strip non-digits prefix/suffix like rc, beta
|
||||
num = "".join(ch for ch in p if ch.isdigit())
|
||||
parts.append(int(num) if num else 0)
|
||||
# Normalize length
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
return tuple(parts[:3])
|
||||
|
||||
try:
|
||||
is_newer = parse_ver(latest_version) > parse_ver(current_version)
|
||||
except Exception:
|
||||
is_newer = latest_version != current_version
|
||||
|
||||
return Response(
|
||||
{
|
||||
"current_version": current_version,
|
||||
"latest_version": latest_version,
|
||||
"latest_url": data.get("html_url"),
|
||||
"is_newer": bool(is_newer),
|
||||
"published_at": data.get("published_at"),
|
||||
"release_name": data.get("name"),
|
||||
"prerelease": data.get("prerelease", False),
|
||||
}
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Trigger rehashing of all streams",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import mantineTheme from './mantineTheme';
|
|||
import API from './api';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import M3URefreshNotification from './components/M3URefreshNotification';
|
||||
import UpdatePromptManager from './components/UpdatePromptManager';
|
||||
import 'allotment/dist/style.css';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
|
@ -164,6 +165,7 @@ const App = () => {
|
|||
</AppShell.Main>
|
||||
</AppShell>
|
||||
<M3URefreshNotification />
|
||||
<UpdatePromptManager />
|
||||
<Notifications containerWidth={350} />
|
||||
</Router>
|
||||
</WebsocketProvider>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import useLogosStore from './store/logos';
|
|||
import usePlaylistsStore from './store/playlists';
|
||||
import useEPGsStore from './store/epgs';
|
||||
import { Box, Button, Stack, Alert, Group } from '@mantine/core';
|
||||
import useNotificationsStore from './store/notifications';
|
||||
import API from './api';
|
||||
import useSettingsStore from './store/settings';
|
||||
import useAuthStore from './store/auth';
|
||||
|
|
@ -296,10 +297,12 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
|
||||
case 'epg_channels':
|
||||
notifications.show({
|
||||
message: 'EPG channels updated!',
|
||||
color: 'green.5',
|
||||
});
|
||||
if (useNotificationsStore.getState().isEnabled('epg_events')) {
|
||||
notifications.show({
|
||||
message: 'EPG channels updated!',
|
||||
color: 'green.5',
|
||||
});
|
||||
}
|
||||
|
||||
// If source_id is provided, update that specific EPG's status
|
||||
if (parsedEvent.data.source_id) {
|
||||
|
|
@ -316,10 +319,12 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
|
||||
case 'epg_match':
|
||||
notifications.show({
|
||||
message: parsedEvent.data.message || 'EPG match is complete!',
|
||||
color: 'green.5',
|
||||
});
|
||||
if (useNotificationsStore.getState().isEnabled('epg_events')) {
|
||||
notifications.show({
|
||||
message: parsedEvent.data.message || 'EPG match is complete!',
|
||||
color: 'green.5',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we have associations data and use the more efficient batch API
|
||||
if (
|
||||
|
|
@ -354,10 +359,12 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
|
||||
case 'recording_started':
|
||||
notifications.show({
|
||||
title: 'Recording started!',
|
||||
message: `Started recording channel ${parsedEvent.data.channel}`,
|
||||
});
|
||||
if (useNotificationsStore.getState().isEnabled('epg_events')) {
|
||||
notifications.show({
|
||||
title: 'Recording started!',
|
||||
message: `Started recording channel ${parsedEvent.data.channel}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
|
|
@ -366,10 +373,12 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
|
||||
case 'recording_ended':
|
||||
notifications.show({
|
||||
title: 'Recording finished!',
|
||||
message: `Stopped recording channel ${parsedEvent.data.channel}`,
|
||||
});
|
||||
if (useNotificationsStore.getState().isEnabled('epg_events')) {
|
||||
notifications.show({
|
||||
title: 'Recording finished!',
|
||||
message: `Stopped recording channel ${parsedEvent.data.channel}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,18 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getLatestRelease() {
|
||||
try {
|
||||
const response = await request(`${host}/api/core/latest-release/`, {
|
||||
auth: false,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to check latest release', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async checkSetting(values) {
|
||||
const { id, ...payload } = values;
|
||||
|
||||
|
|
@ -1854,6 +1866,25 @@ export default class API {
|
|||
return await request(`${host}/api/accounts/users/me/`);
|
||||
}
|
||||
|
||||
static async getMyPreferences() {
|
||||
try {
|
||||
return await request(`${host}/api/accounts/users/me/preferences/`);
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch preferences', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateMyPreferences(body) {
|
||||
try {
|
||||
return await request(`${host}/api/accounts/users/me/preferences/`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update preferences', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getUsers() {
|
||||
try {
|
||||
const response = await request(`${host}/api/accounts/users/`);
|
||||
|
|
|
|||
118
frontend/src/components/UpdateIgnoreDialog.jsx
Normal file
118
frontend/src/components/UpdateIgnoreDialog.jsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Badge,
|
||||
Anchor,
|
||||
Paper,
|
||||
Radio,
|
||||
SimpleGrid,
|
||||
ThemeIcon,
|
||||
} from '@mantine/core';
|
||||
import { Bell, ExternalLink } from 'lucide-react';
|
||||
|
||||
const ChoiceCard = ({ value, selected, title, description, onSelect, color }) => (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow={selected ? 'md' : 'xs'}
|
||||
p="md"
|
||||
radius="md"
|
||||
onClick={() => onSelect(value)}
|
||||
style={{ cursor: 'pointer', borderColor: selected ? `var(--mantine-color-${color}-6)` : undefined }}
|
||||
>
|
||||
<Group align="flex-start" gap="sm">
|
||||
<Radio checked={selected} onChange={() => onSelect(value)} value={value} aria-label={title} />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Text fw={600}>{title}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
const UpdateIgnoreDialog = ({ opened, onClose, version, url, onChoice }) => {
|
||||
const [selection, setSelection] = useState('later');
|
||||
|
||||
const save = () => {
|
||||
onChoice(selection);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="md"
|
||||
size="md"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<ThemeIcon color="blue" radius="xl" variant="light">
|
||||
<Bell size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Update Available</Text>
|
||||
</Group>
|
||||
}
|
||||
overlayProps={{ blur: 2, opacity: 0.35 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={500}>
|
||||
Dispatcharr <Badge color="blue" variant="light">{version}</Badge> is available
|
||||
</Text>
|
||||
{url && (
|
||||
<Anchor href={url} target="_blank" rel="noreferrer" size="sm">
|
||||
View release <ExternalLink size={14} style={{ marginLeft: 4, verticalAlign: 'text-bottom' }} />
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Choose how you want to be notified about this update. You can change this later in Settings → Notifications.
|
||||
</Text>
|
||||
|
||||
<Radio.Group value={selection} onChange={setSelection}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 1 }} spacing="sm">
|
||||
<ChoiceCard
|
||||
value="later"
|
||||
selected={selection === 'later'}
|
||||
title="Remind me next login"
|
||||
description="We’ll show this update again the next time you sign in."
|
||||
onSelect={setSelection}
|
||||
color="blue"
|
||||
/>
|
||||
<ChoiceCard
|
||||
value="ignore_version"
|
||||
selected={selection === 'ignore_version'}
|
||||
title="Ignore this version"
|
||||
description="Skip notifications for this specific version. We’ll notify you when a newer version is available."
|
||||
onSelect={setSelection}
|
||||
color="yellow"
|
||||
/>
|
||||
<ChoiceCard
|
||||
value="never"
|
||||
selected={selection === 'never'}
|
||||
title="Never notify"
|
||||
description="Turn off update notifications entirely. You can re-enable them in Settings."
|
||||
onSelect={setSelection}
|
||||
color="red"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Radio.Group>
|
||||
|
||||
<Group justify="space-between" mt="xs">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save}>Save preference</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateIgnoreDialog;
|
||||
41
frontend/src/components/UpdatePromptManager.jsx
Normal file
41
frontend/src/components/UpdatePromptManager.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import UpdateIgnoreDialog from './UpdateIgnoreDialog';
|
||||
import useUpdatePromptStore from '../store/updatePrompt';
|
||||
import useNotificationsStore from '../store/notifications';
|
||||
|
||||
const UpdatePromptManager = () => {
|
||||
const { open, version, url, close } = useUpdatePromptStore();
|
||||
const notifStore = useNotificationsStore();
|
||||
|
||||
const onChoice = async (choice) => {
|
||||
try {
|
||||
if (choice === 'later') {
|
||||
notifStore.setLastShownNow();
|
||||
await notifStore.save();
|
||||
} else if (choice === 'ignore_version') {
|
||||
notifStore.addIgnoredVersion(version);
|
||||
notifStore.setLastShownNow();
|
||||
await notifStore.save();
|
||||
} else if (choice === 'never') {
|
||||
notifStore.setUpdatePolicy('never');
|
||||
await notifStore.save();
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UpdateIgnoreDialog
|
||||
opened={open}
|
||||
version={version}
|
||||
url={url}
|
||||
onClose={close}
|
||||
onChoice={onChoice}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePromptManager;
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import { Paper, Title, TextInput, Button, Center, Stack } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
import useNotificationsStore from '../../store/notifications';
|
||||
import useUpdatePromptStore from '../../store/updatePrompt';
|
||||
|
||||
const LoginForm = () => {
|
||||
const login = useAuthStore((s) => s.login);
|
||||
|
|
@ -11,6 +15,8 @@ const LoginForm = () => {
|
|||
|
||||
const navigate = useNavigate(); // Hook to navigate to other routes
|
||||
const [formData, setFormData] = useState({ username: '', password: '' });
|
||||
const notifStore = useNotificationsStore();
|
||||
const openUpdatePrompt = useUpdatePromptStore((s) => s.openWith);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (isAuthenticated) {
|
||||
|
|
@ -31,6 +37,51 @@ const LoginForm = () => {
|
|||
|
||||
try {
|
||||
await initData();
|
||||
// After successful login and data init, check for updates per user prefs
|
||||
try {
|
||||
const latest = await API.getLatestRelease();
|
||||
if (latest && latest.is_newer) {
|
||||
const prefs = notifStore.prefs?.notifications || {};
|
||||
const updates = prefs.updates || {};
|
||||
const ignored = new Set(updates.ignored_versions || []);
|
||||
const policy = updates.policy || 'on_login';
|
||||
|
||||
// Respect policy and ignored versions
|
||||
if (!ignored.has(latest.latest_version)) {
|
||||
const now = new Date();
|
||||
let allow = false;
|
||||
if (policy === 'never') allow = false;
|
||||
else if (policy === 'on_login') allow = true;
|
||||
else if (policy === 'daily' || policy === 'weekly') {
|
||||
const last = updates.last_shown_at ? new Date(updates.last_shown_at) : null;
|
||||
const diffMs = last ? now - last : Number.POSITIVE_INFINITY;
|
||||
const threshold = policy === 'daily' ? 24 * 3600 * 1000 : 7 * 24 * 3600 * 1000;
|
||||
allow = diffMs >= threshold;
|
||||
} else allow = true;
|
||||
|
||||
if (allow) {
|
||||
notifications.show({
|
||||
title: 'Update available',
|
||||
message: (
|
||||
<span>
|
||||
Dispatcharr {latest.latest_version} is available.{' '}
|
||||
<a href={latest.latest_url} target="_blank" rel="noreferrer">
|
||||
View on GitHub
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
color: 'blue.5',
|
||||
autoClose: false,
|
||||
onClose: () => openUpdatePrompt(latest.latest_version, latest.latest_url),
|
||||
});
|
||||
// Record show time locally (persist on modal choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
console.warn('Update check failed:', e);
|
||||
}
|
||||
navigate('/channels');
|
||||
} catch (e) {
|
||||
console.log(`Failed to login: ${e}`);
|
||||
|
|
@ -75,6 +126,7 @@ const LoginForm = () => {
|
|||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
{null}
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import {
|
|||
} from '../constants';
|
||||
import ConfirmationDialog from '../components/ConfirmationDialog';
|
||||
import useWarningsStore from '../store/warnings';
|
||||
import useNotificationsStore, { DEFAULT_CATEGORIES } from '../store/notifications';
|
||||
import { notifications as toast } from '@mantine/notifications';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
|
@ -61,6 +63,12 @@ const SettingsPage = () => {
|
|||
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
|
||||
const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
|
||||
const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
|
||||
const [currentVersion, setCurrentVersion] = useState(null);
|
||||
const [latestRelease, setLatestRelease] = useState(null);
|
||||
const notifPrefs = useNotificationsStore((s) => s.prefs);
|
||||
const setCategory = useNotificationsStore((s) => s.setCategory);
|
||||
const setUpdatePolicy = useNotificationsStore((s) => s.setUpdatePolicy);
|
||||
const saveNotifPrefs = useNotificationsStore((s) => s.save);
|
||||
|
||||
const regionChoices = REGION_CHOICES;
|
||||
|
||||
|
|
@ -170,6 +178,29 @@ const SettingsPage = () => {
|
|||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load version info for Notification settings
|
||||
(async () => {
|
||||
try {
|
||||
const v = await API.getVersion();
|
||||
setCurrentVersion(v?.version || null);
|
||||
} catch {}
|
||||
try {
|
||||
const r = await API.getLatestRelease();
|
||||
setLatestRelease(r || null);
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSaveNotifications = async () => {
|
||||
try {
|
||||
await saveNotifPrefs();
|
||||
toast.show({ title: 'Notification settings saved' });
|
||||
} catch (e) {
|
||||
// errorNotification handled in API
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const changedSettings = {};
|
||||
|
|
@ -823,6 +854,73 @@ const SettingsPage = () => {
|
|||
</form>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="notifications">
|
||||
<Accordion.Control>
|
||||
<Box>Notifications</Box>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>Version</Text>
|
||||
<Text size="sm">Current: {currentVersion || '—'}</Text>
|
||||
<Text size="sm">
|
||||
Latest: {latestRelease?.latest_version || '—'}{' '}
|
||||
{latestRelease?.latest_url && (
|
||||
<a
|
||||
href={latestRelease.latest_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ marginLeft: 6 }}
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Select
|
||||
label="Update notification frequency"
|
||||
value={
|
||||
notifPrefs?.notifications?.updates?.policy || 'on_login'
|
||||
}
|
||||
onChange={(v) => setUpdatePolicy(v)}
|
||||
data={[
|
||||
{ value: 'on_login', label: 'On login' },
|
||||
{ value: 'daily', label: 'Once per day (on login)' },
|
||||
{ value: 'weekly', label: 'Once per week (on login)' },
|
||||
{ value: 'never', label: 'Never' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
App notifications
|
||||
</Text>
|
||||
{Object.entries(DEFAULT_CATEGORIES).map(([k, _]) => (
|
||||
<Group key={k} justify="space-between" mt={8}>
|
||||
<Text size="sm" style={{ textTransform: 'capitalize' }}>
|
||||
{k.replaceAll('_', ' ')}
|
||||
</Text>
|
||||
<Switch
|
||||
checked={
|
||||
(notifPrefs?.notifications?.categories || {})[k] !==
|
||||
false
|
||||
}
|
||||
onChange={(e) => setCategory(k, e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button variant="default" onClick={onSaveNotifications}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</>
|
||||
)}
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import useUserAgentsStore from './userAgents';
|
|||
import useUsersStore from './users';
|
||||
import useLogosStore from './logos';
|
||||
import API from '../api';
|
||||
import useNotificationsStore from './notifications';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
|
||||
const decodeToken = (token) => {
|
||||
|
|
@ -63,6 +64,13 @@ const useAuthStore = create((set, get) => ({
|
|||
await Promise.all([useUsersStore.getState().fetchUsers()]);
|
||||
}
|
||||
|
||||
// Load notification preferences from user custom_properties
|
||||
try {
|
||||
useNotificationsStore.getState().loadFromUser(user);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load notification prefs:', e);
|
||||
}
|
||||
|
||||
set({ user, isAuthenticated: true });
|
||||
} catch (error) {
|
||||
console.error('Error initializing data:', error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useNotificationsStore from './notifications';
|
||||
|
||||
const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } };
|
||||
|
||||
|
|
@ -332,11 +333,13 @@ const useChannelsStore = create((set, get) => ({
|
|||
const channel = channelId ? channels[channelId] : null;
|
||||
|
||||
if (channel) {
|
||||
notifications.show({
|
||||
title: 'New channel streaming',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
if (useNotificationsStore.getState().isEnabled('channel_stream_started')) {
|
||||
notifications.show({
|
||||
title: 'New channel streaming',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -345,11 +348,13 @@ const useChannelsStore = create((set, get) => ({
|
|||
// This check prevents the notifications if streams are active on page load
|
||||
if (currentStats.channels) {
|
||||
if (oldClients[client.client_id] === undefined) {
|
||||
notifications.show({
|
||||
title: 'New client started streaming',
|
||||
message: `Client streaming from ${client.ip_address}`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
if (useNotificationsStore.getState().isEnabled('client_stream_started')) {
|
||||
notifications.show({
|
||||
title: 'New client started streaming',
|
||||
message: `Client streaming from ${client.ip_address}`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -363,28 +368,32 @@ const useChannelsStore = create((set, get) => ({
|
|||
const channelId = channelsByUUID[uuid];
|
||||
const channel = channelId && channels[channelId];
|
||||
|
||||
if (channel) {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: `Channel (${uuid})`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
if (useNotificationsStore.getState().isEnabled('channel_stream_stopped')) {
|
||||
if (channel) {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: `Channel (${uuid})`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const clientId in oldClients) {
|
||||
if (newClients[clientId] === undefined) {
|
||||
notifications.show({
|
||||
title: 'Client stopped streaming',
|
||||
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
if (useNotificationsStore.getState().isEnabled('client_stream_stopped')) {
|
||||
notifications.show({
|
||||
title: 'Client stopped streaming',
|
||||
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
frontend/src/store/notifications.jsx
Normal file
122
frontend/src/store/notifications.jsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
|
||||
// Categories we support gating notifications for
|
||||
export const DEFAULT_CATEGORIES = {
|
||||
channel_stream_started: true,
|
||||
channel_stream_stopped: true,
|
||||
client_stream_started: true,
|
||||
client_stream_stopped: true,
|
||||
epg_events: true, // group flag for EPG related notifications
|
||||
};
|
||||
|
||||
const defaultPrefs = {
|
||||
notifications: {
|
||||
updates: {
|
||||
policy: 'on_login', // on_login | daily | weekly | never
|
||||
ignored_versions: [],
|
||||
last_shown_at: null,
|
||||
},
|
||||
categories: { ...DEFAULT_CATEGORIES },
|
||||
},
|
||||
};
|
||||
|
||||
const normalize = (prefs) => {
|
||||
const merged = { ...defaultPrefs.notifications, ...(prefs?.notifications || {}) };
|
||||
merged.categories = { ...DEFAULT_CATEGORIES, ...(merged.categories || {}) };
|
||||
if (!Array.isArray(merged.updates?.ignored_versions)) {
|
||||
merged.updates.ignored_versions = merged.updates?.ignored_versions || [];
|
||||
}
|
||||
return { notifications: merged };
|
||||
};
|
||||
|
||||
const useNotificationsStore = create((set, get) => ({
|
||||
prefs: defaultPrefs,
|
||||
|
||||
loadFromUser: (user) => {
|
||||
const incoming = user?.custom_properties || {};
|
||||
const normalized = normalize(incoming);
|
||||
set({ prefs: normalized });
|
||||
},
|
||||
|
||||
refreshFromServer: async () => {
|
||||
const prefs = await API.getMyPreferences();
|
||||
const normalized = normalize(prefs || {});
|
||||
set({ prefs: normalized });
|
||||
},
|
||||
|
||||
isEnabled: (categoryKey) => {
|
||||
const categories = get().prefs.notifications.categories || {};
|
||||
return categories[categoryKey] !== false;
|
||||
},
|
||||
|
||||
setCategory: (categoryKey, value) => {
|
||||
set((state) => ({
|
||||
prefs: {
|
||||
...state.prefs,
|
||||
notifications: {
|
||||
...state.prefs.notifications,
|
||||
categories: {
|
||||
...state.prefs.notifications.categories,
|
||||
[categoryKey]: !!value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setUpdatePolicy: (policy) => {
|
||||
set((state) => ({
|
||||
prefs: {
|
||||
...state.prefs,
|
||||
notifications: {
|
||||
...state.prefs.notifications,
|
||||
updates: {
|
||||
...state.prefs.notifications.updates,
|
||||
policy,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
addIgnoredVersion: (version) => {
|
||||
set((state) => {
|
||||
const list = new Set(state.prefs.notifications.updates.ignored_versions || []);
|
||||
if (version) list.add(version);
|
||||
return {
|
||||
prefs: {
|
||||
...state.prefs,
|
||||
notifications: {
|
||||
...state.prefs.notifications,
|
||||
updates: {
|
||||
...state.prefs.notifications.updates,
|
||||
ignored_versions: Array.from(list),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setLastShownNow: () => {
|
||||
set((state) => ({
|
||||
prefs: {
|
||||
...state.prefs,
|
||||
notifications: {
|
||||
...state.prefs.notifications,
|
||||
updates: {
|
||||
...state.prefs.notifications.updates,
|
||||
last_shown_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
save: async () => {
|
||||
await API.updateMyPreferences(get().prefs);
|
||||
},
|
||||
}));
|
||||
|
||||
export default useNotificationsStore;
|
||||
12
frontend/src/store/updatePrompt.jsx
Normal file
12
frontend/src/store/updatePrompt.jsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
const useUpdatePromptStore = create((set) => ({
|
||||
open: false,
|
||||
version: null,
|
||||
url: null,
|
||||
openWith: (version, url) => set({ open: true, version, url }),
|
||||
close: () => set({ open: false }),
|
||||
}));
|
||||
|
||||
export default useUpdatePromptStore;
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue