diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py
index c4b544b0..f751922b 100644
--- a/apps/accounts/api_views.py
+++ b/apps/accounts/api_views.py
@@ -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):
diff --git a/core/api_urls.py b/core/api_urls.py
index 00e20a6e..ce71706c 100644
--- a/core/api_urls.py
+++ b/core/api_urls.py
@@ -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)),
]
diff --git a/core/api_views.py b/core/api_views.py
index 6b9743f6..c962e5b9 100644
--- a/core/api_views.py
+++ b/core/api_views.py
@@ -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",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 04555488..895ed902 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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 = () => {
+
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 67f7edfc..b1e1905f 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -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) {
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 0c763dd8..762a3f5b 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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/`);
diff --git a/frontend/src/components/UpdateIgnoreDialog.jsx b/frontend/src/components/UpdateIgnoreDialog.jsx
new file mode 100644
index 00000000..53cd05ce
--- /dev/null
+++ b/frontend/src/components/UpdateIgnoreDialog.jsx
@@ -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 }) => (
+ onSelect(value)}
+ style={{ cursor: 'pointer', borderColor: selected ? `var(--mantine-color-${color}-6)` : undefined }}
+ >
+
+ onSelect(value)} value={value} aria-label={title} />
+
+ {title}
+
+ {description}
+
+
+
+
+);
+
+const UpdateIgnoreDialog = ({ opened, onClose, version, url, onChoice }) => {
+ const [selection, setSelection] = useState('later');
+
+ const save = () => {
+ onChoice(selection);
+ };
+
+ return (
+
+
+
+
+ Update Available
+
+ }
+ overlayProps={{ blur: 2, opacity: 0.35 }}
+ >
+
+
+
+ Dispatcharr {version} is available
+
+ {url && (
+
+ View release
+
+ )}
+
+
+
+ Choose how you want to be notified about this update. You can change this later in Settings → Notifications.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default UpdateIgnoreDialog;
diff --git a/frontend/src/components/UpdatePromptManager.jsx b/frontend/src/components/UpdatePromptManager.jsx
new file mode 100644
index 00000000..b90b6cc6
--- /dev/null
+++ b/frontend/src/components/UpdatePromptManager.jsx
@@ -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 (
+
+ );
+};
+
+export default UpdatePromptManager;
diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx
index 916a2c30..43557c17 100644
--- a/frontend/src/components/forms/LoginForm.jsx
+++ b/frontend/src/components/forms/LoginForm.jsx
@@ -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: (
+
+ Dispatcharr {latest.latest_version} is available.{' '}
+
+ View on GitHub
+
+
+ ),
+ 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 = () => {
+ {null}
);
};
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 85ea85f1..bdb15901 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -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 = () => {
+
+
+
+ Notifications
+
+
+
+
+ Version
+ Current: {currentVersion || '—'}
+
+ Latest: {latestRelease?.latest_version || '—'}{' '}
+ {latestRelease?.latest_url && (
+
+ View on GitHub
+
+ )}
+
+
+
+
+
+
>
)}
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index fd2c52b8..29dcf851 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -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);
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx
index 97e42f06..1ed3c095 100644
--- a/frontend/src/store/channels.jsx
+++ b/frontend/src/store/channels.jsx
@@ -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',
+ });
+ }
}
}
}
diff --git a/frontend/src/store/notifications.jsx b/frontend/src/store/notifications.jsx
new file mode 100644
index 00000000..801a8619
--- /dev/null
+++ b/frontend/src/store/notifications.jsx
@@ -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;
diff --git a/frontend/src/store/updatePrompt.jsx b/frontend/src/store/updatePrompt.jsx
new file mode 100644
index 00000000..36d49ac0
--- /dev/null
+++ b/frontend/src/store/updatePrompt.jsx
@@ -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;
+