feat: push notifikace pro připomínku výběru oběda
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
This commit is contained in:
28
client/public/sw.js
Normal file
28
client/public/sw.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Service Worker pro Web Push notifikace (připomínka výběru oběda)
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/favicon.ico',
|
||||
tag: 'lunch-reminder',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
// Pokud je již otevřené okno, zaostříme na něj
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Jinak otevřeme nové
|
||||
return self.clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { Modal, Button, Form } from "react-bootstrap"
|
||||
import { useSettings, ThemePreference } from "../../context/settings";
|
||||
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
||||
import { useAuth } from "../../context/auth";
|
||||
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
@@ -19,6 +20,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
||||
const themeRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
const reminderTimeRef = useRef<HTMLSelectElement>(null);
|
||||
const ntfyTopicRef = useRef<HTMLInputElement>(null);
|
||||
const discordWebhookRef = useRef<HTMLInputElement>(null);
|
||||
const teamsWebhookRef = useRef<HTMLInputElement>(null);
|
||||
@@ -43,6 +45,9 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const newReminderTime = reminderTimeRef.current?.value || undefined;
|
||||
const oldReminderTime = notifSettings.reminderTime;
|
||||
|
||||
// Uložení notifikačních nastavení na server
|
||||
await updateNotificationSettings({
|
||||
body: {
|
||||
@@ -50,9 +55,17 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
|
||||
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
|
||||
enabledEvents,
|
||||
reminderTime: newReminderTime,
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Správa push subscription pro připomínky
|
||||
if (newReminderTime && newReminderTime !== oldReminderTime) {
|
||||
subscribeToPush(newReminderTime);
|
||||
} else if (!newReminderTime && oldReminderTime) {
|
||||
unsubscribeFromPush();
|
||||
}
|
||||
|
||||
// Uložení ostatních nastavení (localStorage)
|
||||
onSave(
|
||||
bankAccountRef.current?.value,
|
||||
@@ -102,6 +115,29 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
|
||||
</p>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Připomínka výběru oběda</Form.Label>
|
||||
<Form.Select
|
||||
ref={reminderTimeRef}
|
||||
defaultValue={notifSettings.reminderTime ?? ''}
|
||||
key={notifSettings.reminderTime ?? 'reminder-empty'}
|
||||
>
|
||||
<option value="">Vypnuto</option>
|
||||
<option value="10:00">10:00</option>
|
||||
<option value="10:30">10:30</option>
|
||||
<option value="11:00">11:00</option>
|
||||
<option value="11:30">11:30</option>
|
||||
<option value="12:00">12:00</option>
|
||||
<option value="12:30">12:30</option>
|
||||
<option value="13:00">13:00</option>
|
||||
<option value="13:30">13:30</option>
|
||||
<option value="14:00">14:00</option>
|
||||
</Form.Select>
|
||||
<Form.Text className="text-muted">
|
||||
V zadaný čas vám přijde push notifikace, pokud nemáte zvolenou možnost stravování.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>ntfy téma (topic)</Form.Label>
|
||||
<Form.Control
|
||||
|
||||
108
client/src/hooks/usePushReminder.ts
Normal file
108
client/src/hooks/usePushReminder.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { getToken } from '../Utils';
|
||||
|
||||
/** Převede base64url VAPID klíč na Uint8Array pro PushManager. */
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/** Helper pro autorizované API volání na push endpointy. */
|
||||
async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const token = getToken();
|
||||
return fetch(`/api/notifications/push${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaregistruje service worker, přihlásí se k push notifikacím
|
||||
* a odešle subscription na server.
|
||||
*/
|
||||
export async function subscribeToPush(reminderTime: string): Promise<boolean> {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Registrace service workeru
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Vyžádání oprávnění
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('Push notifikace: oprávnění zamítnuto');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Získání VAPID veřejného klíče ze serveru
|
||||
const vapidResponse = await pushApiFetch('/vapidKey');
|
||||
if (!vapidResponse.ok) {
|
||||
console.error('Push notifikace: nepodařilo se získat VAPID klíč');
|
||||
return false;
|
||||
}
|
||||
const { key: vapidPublicKey } = await vapidResponse.json();
|
||||
|
||||
// Přihlášení k push
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
|
||||
});
|
||||
|
||||
// Odeslání subscription na server
|
||||
const response = await pushApiFetch('/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
subscription: subscription.toJSON(),
|
||||
reminderTime,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Push notifikace: nepodařilo se odeslat subscription na server');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Push notifikace: chyba při registraci', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Odhlásí se z push notifikací a informuje server.
|
||||
*/
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration('/sw.js');
|
||||
if (registration) {
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
await pushApiFetch('/unsubscribe', { method: 'POST' });
|
||||
console.log('Push notifikace: úspěšně odhlášeno z připomínek');
|
||||
} catch (error) {
|
||||
console.error('Push notifikace: chyba při odhlášení', error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user