Skip to content

Commit 50aee63

Browse files
committed
feat: add notification debugger to settings
1 parent 59dd5d4 commit 50aee63

3 files changed

Lines changed: 194 additions & 149 deletions

File tree

src/notifications/firebase.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { initializeApp } from "firebase/app";
22
import { getMessaging, getToken } from "firebase/messaging";
3-
import { configPromise } from "../config";
43

54
const FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_FIREBASE_CONFIG);
65
const FIREBASE_VAPID_KEY = import.meta.env.VITE_FIREBASE_VAPID_KEY;
@@ -18,6 +17,38 @@ function rejectAfter(ms: number, message: string): Promise<never> {
1817
);
1918
}
2019

20+
export async function getFirebaseToken(): Promise<string> {
21+
const registration = await Promise.race([
22+
navigator.serviceWorker.ready,
23+
rejectAfter(SW_READY_TIMEOUT_MS, "Service worker not ready"),
24+
]);
25+
26+
const token = await Promise.race([
27+
getToken(messaging, {
28+
vapidKey: FIREBASE_VAPID_KEY,
29+
serviceWorkerRegistration: registration,
30+
}),
31+
rejectAfter(GET_TOKEN_TIMEOUT_MS, "getToken timed out"),
32+
]);
33+
34+
if (!token) throw new Error("No token returned from Firebase");
35+
return token;
36+
}
37+
38+
export async function requestAndRegisterForPushNotifications(): Promise<
39+
NotificationPermission | "error"
40+
> {
41+
const permission = await Notification.requestPermission();
42+
if (permission !== "granted") return permission;
43+
try {
44+
await registerForPushNotifications();
45+
return "granted";
46+
} catch (e) {
47+
console.error("Failed to register for push notifications:", e);
48+
return "error";
49+
}
50+
}
51+
2152
export async function registerForPushNotifications(): Promise<void> {
2253
const registration = await Promise.race([
2354
navigator.serviceWorker.ready,
Lines changed: 158 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
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";
83
import { createFileRoute } from "@tanstack/react-router";
4+
import { T, useTranslate } from "@tolgee/react";
95
import { useState } from "react";
106
import { 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

1513
export 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

Comments
 (0)