import { useCallback, useEffect, useState } from 'react'; import { ArrowLeft, BellOff, BellRing, Link, Pencil, Plus, Trash2 } from 'lucide-react'; import { request } from '../api/client.js'; import { ConfirmDialog } from '../components/ConfirmDialog.jsx'; import { Field } from '../components/Field.jsx'; import { useToast } from '../components/Toast.jsx'; function validateWebhookForm(form) { if (!form.alias.trim()) { throw new Error('エイリアス名を入力してください'); } if (form.alias.trim().length > 120) { throw new Error('エイリアス名は120文字以内で入力してください'); } if (!form.url.trim()) { throw new Error('Webhook URL を入力してください'); } const url = new URL(form.url.trim()); if (url.protocol !== 'https:') { throw new Error('Webhook URL は HTTPS で入力してください'); } } function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = `${base64String}${padding}`.replaceAll('-', '+').replaceAll('_', '/'); const rawData = window.atob(base64); return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); } function permissionText(permission) { if (permission === 'unsupported') return '非対応'; if (permission === 'granted') return '許可済み'; if (permission === 'denied') return '拒否されています'; return '未設定'; } function pushStatusText(status) { if (status === 'checking') return '確認中'; if (status === 'registered') return 'このブラウザは登録済みです'; if (status === 'unregistered') return 'このブラウザは未登録です'; if (status === 'no-subscription') return 'このブラウザは未登録です'; if (status === 'permission-denied') return 'ブラウザ通知が拒否されています'; if (status === 'unconfigured') return 'VAPID public key が未設定です'; if (status === 'unsupported') return 'このブラウザは非対応です'; if (status === 'error') return '状態を確認できませんでした'; return '未確認'; } async function getPushRegistration() { return navigator.serviceWorker.register('/push-sw.js'); } export function NotificationMethodsView({ onBack }) { const [webhooks, setWebhooks] = useState([]); const [vapidPublicKey, setVapidPublicKey] = useState(''); const [currentPushStatus, setCurrentPushStatus] = useState('unchecked'); const [form, setForm] = useState({ alias: '', url: '' }); const [editingId, setEditingId] = useState(''); const [permission, setPermission] = useState( typeof Notification === 'undefined' ? 'unsupported' : Notification.permission, ); const [busy, setBusy] = useState(false); const { showToast } = useToast(); const refreshCurrentPushStatus = useCallback( async (publicKey = vapidPublicKey) => { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { setCurrentPushStatus('unsupported'); return; } if (!publicKey) { setCurrentPushStatus('unconfigured'); return; } if (typeof Notification === 'undefined') { setCurrentPushStatus('unsupported'); return; } setPermission(Notification.permission); if (Notification.permission === 'denied') { setCurrentPushStatus('permission-denied'); return; } setCurrentPushStatus('checking'); try { const registration = await getPushRegistration(); const subscription = await registration.pushManager.getSubscription(); if (!subscription) { setCurrentPushStatus('no-subscription'); return; } const data = await request('/api/notification-methods/push-subscription-status', { method: 'POST', body: JSON.stringify({ endpoint: subscription.endpoint }), }); setCurrentPushStatus(data.registered ? 'registered' : 'unregistered'); } catch (err) { setCurrentPushStatus('error'); showToast({ type: 'error', message: err.message }); } }, [showToast, vapidPublicKey], ); const loadMethods = useCallback(async () => { const data = await request('/api/notification-methods'); setWebhooks(data.webhooks); setVapidPublicKey(data.vapidPublicKey); await refreshCurrentPushStatus(data.vapidPublicKey); }, [refreshCurrentPushStatus]); useEffect(() => { loadMethods().catch((err) => showToast({ type: 'error', message: err.message })); }, [loadMethods, showToast]); async function submitWebhook(event) { event.preventDefault(); setBusy(true); try { validateWebhookForm(form); const endpoint = editingId ? `/api/notification-methods/webhooks/${editingId}` : '/api/notification-methods/webhooks'; await request(endpoint, { method: editingId ? 'PATCH' : 'POST', body: JSON.stringify({ alias: form.alias.trim(), url: form.url.trim() }), }); setForm({ alias: '', url: '' }); setEditingId(''); showToast({ type: 'success', message: editingId ? 'Webhookを更新しました' : 'Webhookを登録しました', }); await loadMethods(); } catch (err) { showToast({ type: 'error', message: err.message }); } finally { setBusy(false); } } function startEdit(webhook) { setEditingId(webhook.notificationMethodId); setForm({ alias: webhook.alias, url: webhook.url }); } async function deleteWebhook(methodId) { setBusy(true); try { await request(`/api/notification-methods/webhooks/${methodId}`, { method: 'DELETE' }); showToast({ type: 'success', message: 'Webhookを削除しました' }); await loadMethods(); } catch (err) { showToast({ type: 'error', message: err.message }); } finally { setBusy(false); } } async function subscribePush() { setBusy(true); try { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { throw new Error('このブラウザはプッシュ通知に対応していません'); } if (!vapidPublicKey) { throw new Error('VAPID public key が設定されていません'); } if (typeof Notification === 'undefined') { throw new Error('このブラウザはプッシュ通知に対応していません'); } const nextPermission = await Notification.requestPermission(); setPermission(nextPermission); if (nextPermission !== 'granted') { throw new Error('ブラウザ通知が許可されませんでした'); } const registration = await getPushRegistration(); const existingSubscription = await registration.pushManager.getSubscription(); const subscription = existingSubscription ?? (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), })); await request('/api/notification-methods/push-subscriptions', { method: 'POST', body: JSON.stringify(subscription.toJSON()), }); showToast({ type: 'success', message: 'プッシュ通知を登録しました' }); await refreshCurrentPushStatus(); } catch (err) { showToast({ type: 'error', message: err.message }); } finally { setBusy(false); } } async function unsubscribePush() { setBusy(true); try { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { throw new Error('このブラウザはプッシュ通知に対応していません'); } const registration = await getPushRegistration(); const subscription = await registration.pushManager.getSubscription(); if (!subscription) { await refreshCurrentPushStatus(); throw new Error('解除対象のプッシュ通知登録が見つかりません'); } await request('/api/notification-methods/push-subscriptions', { method: 'DELETE', body: JSON.stringify({ endpoint: subscription.endpoint }), }); await subscription.unsubscribe(); showToast({ type: 'success', message: 'プッシュ通知を解除しました' }); await refreshCurrentPushStatus(); } catch (err) { showToast({ type: 'error', message: err.message }); } finally { setBusy(false); } } return (
通知方法

通知方法の管理

Webhook

Slack互換Webhookとして送信します。

setForm({ ...form, alias: event.target.value })} placeholder="Slack 通知" maxLength="120" required /> setForm({ ...form, url: event.target.value })} placeholder="https://hooks.slack.com/services/..." maxLength="2048" required />

登録済みWebhook

{webhooks.length === 0 ? (
Webhookはまだ登録されていません。
) : ( webhooks.map((webhook) => (
{webhook.alias} {webhook.url}
deleteWebhook(webhook.notificationMethodId)} disabled={busy} trigger={ } />
)) )}
現在のブラウザ {pushStatusText(currentPushStatus)}
{currentPushStatus === 'registered' ? (
); }