1- import {
2- ScoutButton ,
3- ScoutCallout ,
4- ScoutListView ,
5- ScoutListViewItem ,
6- } from "@scouterna/ui-react" ;
7- import { useMutation , useQuery } from "@tanstack/react-query" ;
1+ import { ScoutButton , ScoutCallout , ScoutLoader } from "@scouterna/ui-react" ;
2+ import { useQuery , useQueryClient } from "@tanstack/react-query" ;
83import { createFileRoute } from "@tanstack/react-router" ;
4+ import { T , useTranslate } from "@tolgee/react" ;
95import { useState } from "react" ;
106import { PageContainer } from "../../../components/PageContainer" ;
11- import type { components } from "../../../generated/notification-api" ;
12- import * as api from "../../../notifications/api" ;
13- import { showLocalizedNotification } from "../../../notifications/show-notification" ;
7+ import {
8+ getFirebaseToken ,
9+ registerForPushNotifications ,
10+ requestAndRegisterForPushNotifications ,
11+ } from "../../../notifications/firebase" ;
1412
1513export const Route = createFileRoute ( "/_app/settings/notifications" ) ( {
1614 component : RouteComponent ,
@@ -19,143 +17,172 @@ export const Route = createFileRoute("/_app/settings/notifications")({
1917 } ,
2018} ) ;
2119
22- type Channel = components [ "schemas" ] [ "ChannelRead" ] ;
23-
24- const ChannelRow = ( {
25- channel,
26- subscribed,
27- onToggle,
28- } : {
29- channel : Channel ;
30- subscribed : boolean ;
31- onToggle : ( ) => void ;
32- } ) => {
33- const subscribe = useMutation ( {
34- mutationFn : ( channelId : string ) => api . subscribe ( channelId ) ,
35- onSettled : ( ) => onToggle ( ) ,
36- } ) ;
20+ type CheckStatus = "ok" | "warning" | "error" | "skipped" ;
3721
38- const unsubscribe = useMutation ( {
39- mutationFn : ( subscriptionId : string ) => api . unsubscribe ( subscriptionId ) ,
40- onSettled : ( ) => onToggle ( ) ,
41- } ) ;
22+ interface CheckResult {
23+ status : CheckStatus ;
24+ headingKey : string ;
25+ messageKey ?: string ;
26+ messageParams ?: Record < string , string > ;
27+ }
4228
43- return (
44- < ScoutListViewItem
45- type = "checkbox"
46- key = { channel . id }
47- disabled = { subscribe . isPending || unsubscribe . isPending }
48- primary = { channel . name }
49- secondary = { channel . description ?? undefined }
50- checked = { subscribed }
51- onScoutChecked = { ( ) => {
52- if ( subscribed ) {
53- unsubscribe . mutate ( channel . id ) ;
54- } else {
55- subscribe . mutate ( channel . id ) ;
56- }
57- } }
58- />
59- ) ;
29+ const VARIANT : Record < CheckStatus , "success" | "warning" | "error" | "info" > = {
30+ ok : "success" ,
31+ warning : "warning" ,
32+ error : "error" ,
33+ skipped : "info" ,
6034} ;
6135
62- async function sendTestNotification ( ) {
63- if ( Notification . permission !== "granted" ) return "denied" ;
64-
65- const reg = await navigator . serviceWorker . ready ;
66- await showLocalizedNotification (
67- reg ,
68- JSON . stringify ( {
69- notification : {
70- sv : {
71- title : "Testnotis" ,
72- body : "Det här är en testnotis som går till kartan." ,
73- } ,
74- } ,
75- category : null ,
76- important : false ,
77- link : "/app/map" ,
78- } ) ,
79- ) ;
80- return "sent" ;
36+ async function checkPermission ( ) : Promise < CheckResult > {
37+ const headingKey = "settings.notifications.status.permission.heading" ;
38+ if ( Notification . permission === "granted" )
39+ return { status : "ok" , headingKey, messageKey : "settings.notifications.status.permission.ok" } ;
40+ if ( Notification . permission === "denied" )
41+ return { status : "error" , headingKey, messageKey : "settings.notifications.status.permission.denied" } ;
42+ return { status : "warning" , headingKey, messageKey : "settings.notifications.status.permission.default" } ;
8143}
8244
83- function TestNotificationButton ( ) {
84- const [ status , setStatus ] = useState < "idle" | "sent" | "denied" > ( "idle" ) ;
45+ async function checkSubscription ( ) : Promise < CheckResult > {
46+ const headingKey = "settings.notifications.status.subscription.heading" ;
47+ try {
48+ const reg = await navigator . serviceWorker . ready ;
49+ const sub = await reg . pushManager . getSubscription ( ) ;
50+ if ( sub )
51+ return { status : "ok" , headingKey, messageKey : "settings.notifications.status.subscription.ok" } ;
52+ return { status : "warning" , headingKey, messageKey : "settings.notifications.status.subscription.warning" } ;
53+ } catch ( e ) {
54+ return {
55+ status : "error" ,
56+ headingKey,
57+ messageKey : "settings.notifications.status.subscription.error" ,
58+ messageParams : { error : String ( e ) } ,
59+ } ;
60+ }
61+ }
8562
86- const handleClick = async ( ) => {
87- const result = await sendTestNotification ( ) ;
88- setStatus ( result ) ;
89- if ( result === "sent" ) setTimeout ( ( ) => setStatus ( "idle" ) , 3000 ) ;
90- } ;
63+ async function checkFirebase ( ) : Promise < CheckResult > {
64+ await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) ) ;
65+ const headingKey = "settings.notifications.status.server.heading" ;
66+ try {
67+ await getFirebaseToken ( ) ;
68+ return { status : "ok" , headingKey, messageKey : "settings.notifications.status.server.ok" } ;
69+ } catch ( e ) {
70+ return {
71+ status : "error" ,
72+ headingKey,
73+ messageKey : "settings.notifications.status.server.error" ,
74+ messageParams : { error : String ( e ) } ,
75+ } ;
76+ }
77+ }
9178
92- return (
93- < div className = "p-4 flex flex-col gap-3" >
94- < ScoutButton variant = "outlined" onScoutClick = { handleClick } >
95- Skicka testnotis
96- </ ScoutButton >
97- { status === "sent" && (
98- < ScoutCallout variant = "success" > Testnotis skickad!</ ScoutCallout >
99- ) }
100- { status === "denied" && (
101- < ScoutCallout variant = "error" >
102- Notiser är inte tillåtna. Aktivera dem i telefonens inställningar.
103- </ ScoutCallout >
104- ) }
105- </ div >
106- ) ;
79+ const SKIPPED : Pick < CheckResult , "status" > = {
80+ status : "skipped" ,
81+ } ;
82+
83+ async function runStatusChecks ( ) : Promise < CheckResult [ ] > {
84+ const permission = await checkPermission ( ) ;
85+ if ( permission . status !== "ok" )
86+ return [
87+ permission ,
88+ { ...SKIPPED , headingKey : "settings.notifications.status.subscription.heading" } ,
89+ { ...SKIPPED , headingKey : "settings.notifications.status.server.heading" } ,
90+ ] ;
91+
92+ const subscription = await checkSubscription ( ) ;
93+ if ( subscription . status !== "ok" )
94+ return [
95+ permission ,
96+ subscription ,
97+ { ...SKIPPED , headingKey : "settings.notifications.status.server.heading" } ,
98+ ] ;
99+
100+ const firebase = await checkFirebase ( ) ;
101+ return [ permission , subscription , firebase ] ;
107102}
108103
109- function RouteComponent ( ) {
110- const channels = useQuery ( {
111- queryFn : api . getChannels ,
112- queryKey : [ "notifications" , "channels" ] ,
113- } ) ;
104+ const STATUS_QUERY_KEY = [ "notifications" , "status" ] ;
114105
115- const subscriptions = useQuery ( {
116- queryFn : api . getSubscriptions ,
117- queryKey : [ "notifications" , "subscriptions" ] ,
106+ function NotificationStatus ( ) {
107+ const { t } = useTranslate ( ) ;
108+ const [ registering , setRegistering ] = useState ( false ) ;
109+ const [ permission , setPermission ] = useState < NotificationPermission > (
110+ "Notification" in window ? Notification . permission : "denied" ,
111+ ) ;
112+ const queryClient = useQueryClient ( ) ;
113+ const { data, isLoading, refetch } = useQuery ( {
114+ queryKey : STATUS_QUERY_KEY ,
115+ queryFn : runStatusChecks ,
116+ staleTime : 0 ,
118117 } ) ;
119118
120- const subscribedIds =
121- subscriptions . data ?. map ( ( sub : any ) => sub . channel_id ) || [ ] ;
119+ if ( isLoading ) {
120+ return (
121+ < div className = "flex justify-center p-8" >
122+ < ScoutLoader text = { t ( "settings.notifications.status.checking" ) } />
123+ </ div >
124+ ) ;
125+ }
122126
123127 return (
124- < PageContainer >
125- < div className = "p-4" >
126- < ScoutCallout >
127- Välj vilka notiser du vill ta emot från Jamboree-appen. "Viktiga
128- meddelanden" är alltid påslaget för att Jamboreeledningen ska kunna nå
129- ut med viktig information.
130- </ ScoutCallout >
131- </ div >
128+ < div className = "flex flex-col gap-3 p-4" >
129+ { data
130+ ?. filter ( ( r ) => r . status !== "skipped" )
131+ . map ( ( result ) => (
132+ < ScoutCallout
133+ key = { result . headingKey }
134+ variant = { VARIANT [ result . status ] }
135+ heading = { t ( result . headingKey ) }
136+ >
137+ { result . messageKey && t ( result . messageKey , result . messageParams ) }
138+ </ ScoutCallout >
139+ ) ) }
140+ { /* Keys force full remount when switching — Stencil web components hide
141+ their slot content when React updates children in-place. */ }
142+ { permission === "default" ? (
143+ < ScoutButton
144+ key = "allow"
145+ variant = "primary"
146+ loading = { registering }
147+ onScoutClick = { async ( ) => {
148+ setRegistering ( true ) ;
149+ await requestAndRegisterForPushNotifications ( ) ;
150+ await refetch ( ) ;
151+ setPermission ( Notification . permission ) ;
152+ setRegistering ( false ) ;
153+ } }
154+ >
155+ < T keyName = "settings.notifications.allow" />
156+ </ ScoutButton >
157+ ) : permission === "granted" ? (
158+ < >
159+ < p className = "text-body-sm" >
160+ < T keyName = "settings.notifications.reregister.description" />
161+ </ p >
162+ < ScoutButton
163+ key = "reregister"
164+ variant = "outlined"
165+ onScoutClick = { async ( ) => {
166+ queryClient . resetQueries ( { queryKey : STATUS_QUERY_KEY } ) ;
167+ await registerForPushNotifications ( ) . catch ( ( e ) =>
168+ console . error ( "Failed to re-register for push notifications:" , e ) ,
169+ ) ;
170+ await refetch ( ) ;
171+ } }
172+ >
173+ < T keyName = "settings.notifications.reregister" />
174+ </ ScoutButton >
175+ </ >
176+ ) : null }
177+ </ div >
178+ ) ;
179+ }
180+
132181
133- < TestNotificationButton />
134-
135- < ScoutListView >
136- < ScoutListViewItem
137- type = "checkbox"
138- primary = "Viktiga meddelanden"
139- secondary = "Påslaget för alla användare"
140- disabled
141- checked
142- />
143-
144- { channels . data ?. map ( ( channel : any ) => {
145- const subscribed = subscribedIds . includes ( channel . id ) ;
146-
147- return (
148- < ChannelRow
149- key = { channel . id }
150- channel = { channel }
151- subscribed = { subscribed }
152- onToggle = { ( ) => {
153- channels . refetch ( ) ;
154- } }
155- />
156- ) ;
157- } ) }
158- </ ScoutListView >
182+ function RouteComponent ( ) {
183+ return (
184+ < PageContainer >
185+ < NotificationStatus />
159186 </ PageContainer >
160187 ) ;
161188}
0 commit comments