From 7831f61fb91948f54e6eacb6fadb6f9d6f7fa0be Mon Sep 17 00:00:00 2001 From: Nathan Lamy Date: Wed, 20 Aug 2025 12:22:05 +0200 Subject: [PATCH] feat: add notifications --- app/components/settings/index.tsx | 2 +- app/components/settings/notifications.tsx | 273 ++++++++++++++-------- app/lib/api.ts | 62 ++++- app/lib/notification.ts | 21 +- 4 files changed, 242 insertions(+), 116 deletions(-) diff --git a/app/components/settings/index.tsx b/app/components/settings/index.tsx index c2f1876..5f26e99 100644 --- a/app/components/settings/index.tsx +++ b/app/components/settings/index.tsx @@ -25,7 +25,7 @@ export default function SettingsPage({ user }: { user: User }) { value: "notifications", label: "Notifications", icon: , - content: , + content: , }, ]; diff --git a/app/components/settings/notifications.tsx b/app/components/settings/notifications.tsx index 9d171ba..0b379d1 100644 --- a/app/components/settings/notifications.tsx +++ b/app/components/settings/notifications.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Card, CardContent, @@ -23,8 +23,60 @@ import InstallApp, { isInstalled } from "./install-app"; import { isNotificationEnabled, registerNotification, + STORAGE_KEY, unregisterNotification, } from "~/lib/notification"; +import { + testNotification, + unsubscribe, + updateSubscription, + useNotifications, + type Event, +} from "~/lib/api"; +import { toast } from "sonner"; + +const EVENTS = [ + { + id: "SYSTEM", + label: "Système", + description: + "Soyez informé des mises à jour et des nouveautés de Khollisé.", + }, + { + id: "COLLE_ADDED", + label: "Colle ajoutée", + description: + "Recevez une notification lorsqu'une nouvelle colle est ajoutée par votre CDT.", + }, + { + id: "COLLE_REMOVED", + label: "Colle supprimée", + description: "Recevez une notification lorsqu'une colle est supprimée.", + }, + { + id: "COLLE_UPDATED", + label: "Colle modifiée", + description: + "Soyez averti lorsqu'un colleur modifie le contenu d'une colle.", + }, + + { + id: "GRADE_ADDED", + label: "Note ajoutée", + description: "Recevez une notification lorsqu'une colle est notée.", + }, + { + id: "GRADE_UPDATED", + label: "Note modifiée", + description: "Soyez averti lorsqu'une note est modifiée.", + }, + + { + id: "ROOM_UPDATED", + label: "Salle modifiée", + description: "Soyez averti lorsqu'une colle change de salle.", + }, +]; export default function NotificationSettings() { const [pushEnabled, setPushEnabled] = useState(isNotificationEnabled()); @@ -36,7 +88,7 @@ export default function NotificationSettings() { // Unregister notifications unregisterNotification() .then(() => { - setPushEnabled(true); + setPushEnabled(false); }) .catch((error) => { console.error("Failed to unregister notifications:", error); @@ -51,7 +103,7 @@ export default function NotificationSettings() { if ("error" in result && result.error) { console.error("Failed to register notifications:", result.error); } else { - setPushEnabled(false); + setPushEnabled(true); } }) .catch((error) => { @@ -63,38 +115,9 @@ export default function NotificationSettings() { } } - // TODO: Replace with actual user data - const [events, setEvents] = useState([ - { id: "new-message", label: "New Message", enabled: true }, - { id: "comment-reply", label: "Comment Reply", enabled: true }, - { id: "app-update", label: "App Update", enabled: false }, - { id: "weekly-digest", label: "Weekly Digest", enabled: true }, - { id: "security-alert", label: "Security Alert", enabled: true }, - { id: "friend-request", label: "Friend Request", enabled: false }, - ]); - const [subscriptions] = useState([ - { - id: "1", - name: "iPhone 15 Pro", - type: "mobile", - status: "active", - lastSeen: "2 hours ago", - }, - { - id: "2", - name: "MacBook Pro", - type: "desktop", - status: "active", - lastSeen: "5 minutes ago", - }, - { - id: "3", - name: "Chrome on Windows", - type: "desktop", - status: "revoked", - lastSeen: "3 days ago", - }, - ]); + const [events, setEvents] = useState([]); + // TODO: Loader and error handling + const { notifications, isError, isLoading } = useNotifications(); const toggleEvent = (eventId: string) => { setEvents( @@ -103,6 +126,17 @@ export default function NotificationSettings() { ) ); }; + const hasEvent = (eventId: string) => { + return events.some((event) => event.id === eventId && event.enabled); + }; + + const currentSubscriptionId = localStorage.getItem(STORAGE_KEY); + const currentSubscription = notifications.find( + (sub) => sub.id === currentSubscriptionId + ); + useEffect(() => { + setEvents(currentSubscription?.events || []); + }, [currentSubscription]); return (
@@ -173,52 +207,67 @@ export default function NotificationSettings() { {/* Select Notification Events Section */} - - - Select Notification Events - - Choose which events you want to receive notifications for - - - -
- {events.map((event, index) => ( -
-
-
-

- {event.label} -

-

- {event.id === "new-message" && - "Get notified when you receive a new message"} - {event.id === "comment-reply" && - "Get notified when someone replies to your comment"} - {event.id === "app-update" && - "Get notified when a new app version is available"} - {event.id === "weekly-digest" && - "Receive a weekly summary of your activity"} - {event.id === "security-alert" && - "Important security notifications and alerts"} - {event.id === "friend-request" && - "Get notified when someone sends you a friend request"} -

+ {currentSubscription && ( + + + Select Notification Events + + Choose which events you want to receive notifications for + + + +
+ {EVENTS.map((event, index) => ( +
+
+
+

+ {event.label} +

+

+ {event.description} +

+
+ toggleEvent(event.id)} + aria-label={`Toggle ${event.label} notifications`} + className="flex-shrink-0" + />
- toggleEvent(event.id)} - aria-label={`Toggle ${event.label} notifications`} - className="flex-shrink-0" - /> + {index < events.length - 1 && ( + + )}
- {index < events.length - 1 && ( - - )} -
- ))} -
- - + ))} + +
+ + + )} {/* Manage Subscriptions Section */} @@ -230,23 +279,23 @@ export default function NotificationSettings() {
- {subscriptions.map((subscription, index) => ( + {notifications.map((subscription, index) => (
- {subscription.type === "mobile" ? ( - - ) : ( - - )} +

- {subscription.name} + {subscription.device}

- {subscription.status === "revoked" && ( + {subscription.enabled ? ( + + Active + + ) : ( )} - {subscription.status === "active" && ( - - Active - - )}
-

- Last seen {subscription.lastSeen} -

- {index < subscriptions.length - 1 && ( + {index < notifications.length - 1 && ( )}
))} - {subscriptions.length === 0 && ( + {notifications.length === 0 && (

- No subscribed devices found + Aucun appareil n'est actuellement abonné aux notifications.

- Enable push notifications to add this device + Vous pouvez activer les notifications pour recevoir des + alertes sur vos appareils.

)} diff --git a/app/lib/api.ts b/app/lib/api.ts index 7e0d599..3a34fa3 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -320,16 +320,68 @@ export const useSubjects = () => { */ export const subscribe = async (data: any) => { return makePostRequest( - "/notifications/subscribe", + "/notifications", data, "Échec de l'abonnement aux notifications" ); -} +}; -export const unsubscribe = async (data: any) => { +export const unsubscribe = async (id: string) => { return makePostRequest( - "/notifications/unsubscribe", - data, + `/notifications/${id}/unsubscribe`, + {}, "Échec de la désinscription des notifications" ); }; + +export const updateSubscription = async (id: string, events: string[]) => { + return makePostRequest( + `/notifications/${id}`, + { events }, + "Échec de la mise à jour de l'abonnement aux notifications" + ); +}; + +export const getNotifications = async () => { + return makeRequest( + "/notifications", + "Échec de la récupération des notifications" + ); +}; + +export interface Event { + id: string; + enabled: boolean; +} + +interface Subscription { + id: string; + device: string; + events: Event[]; + enabled: boolean; +} + +export const useNotifications = () => { + const { data, ...props } = useQuery({ + queryKey: ["notifications"], + queryFn: getNotifications, + staleTime: Duration.fromObject({ + hours: 1, // 1 hour + }).toMillis(), + gcTime: Duration.fromObject({ + days: 3, // 3 days + }).toMillis(), + }); + return { + notifications: (data as Subscription[]) || [], + ...props, + }; +}; + +export const testNotification = async (id: string) => { + return makePostRequest( + `/notifications/${id}/test`, + {}, + "Échec de l'envoi de la notification de test" + ); +}; diff --git a/app/lib/notification.ts b/app/lib/notification.ts index ceaa147..ef77bee 100644 --- a/app/lib/notification.ts +++ b/app/lib/notification.ts @@ -1,6 +1,6 @@ import { subscribe, unsubscribe } from "./api"; -const STORAGE_KEY = "notification_enabled"; +export const STORAGE_KEY = "notification_enabled"; export async function registerNotification() { const permission = await Notification.requestPermission(); @@ -20,9 +20,9 @@ export async function registerNotification() { userVisibleOnly: true, applicationServerKey: import.meta.env.VITE_PUBLIC_VAPID_KEY, }); - await subscribe(subscription); + const data = await subscribe(subscription); // Store to local storage - localStorage.setItem(STORAGE_KEY, "true"); + localStorage.setItem(STORAGE_KEY, data.id?.toString()); return subscription; } catch (err) { return { @@ -32,15 +32,16 @@ export async function registerNotification() { } export function isNotificationEnabled() { - return localStorage.getItem(STORAGE_KEY) === "true"; + return !!localStorage.getItem(STORAGE_KEY); } export async function unregisterNotification() { - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - if (subscription) { - await subscription.unsubscribe(); - localStorage.removeItem(STORAGE_KEY); - await unsubscribe(subscription); + const subscriptionId = localStorage.getItem(STORAGE_KEY); + if (!subscriptionId) { + return { + error: "E_NOT_REGISTERED", + }; } + await unsubscribe(subscriptionId); + localStorage.removeItem(STORAGE_KEY); }