Make the app an installable PWA with phone layout and Web Push
Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
|
||||
import { ensureVapidKeys } from "@/src/push/vapid";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import type { PushOverview } from "@/src/types";
|
||||
|
||||
/**
|
||||
* The Notifications page refreshes this user's devices after a change
|
||||
* without a reload.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<PushOverview>
|
||||
> = async ({ req }) => {
|
||||
try {
|
||||
if (req.method != "GET") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: await grabPushOverview({
|
||||
user_id: user.id,
|
||||
public_key: (await ensureVapidKeys().catch(() => null))
|
||||
?.public_key,
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import deletePushSubscriptionById from "@/src/functions/backend/db/push/delete-push-subscription-by-id";
|
||||
import { PushSubscriptionIdRequestSchema } from "@/src/schemas/push-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Removes one of the session user's devices from the settings list. Its
|
||||
* browser keeps its own subscription until it turns notifications off
|
||||
* there, which also removes it again on the next subscribe/unsubscribe.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const { id } = parseWithSchema({
|
||||
schema: PushSubscriptionIdRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
await deletePushSubscriptionById({ user_id: user.id, id });
|
||||
|
||||
return { success: true, msg: "Device removed." };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import savePushSubscription from "@/src/functions/backend/db/push/save-push-subscription";
|
||||
import { SavePushSubscriptionSchema } from "@/src/schemas/push-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Saves this device's push subscription for the session user. The
|
||||
* endpoint is unique, so subscribing again only refreshes the keys.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const data = parseWithSchema({
|
||||
schema: SavePushSubscriptionSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
await savePushSubscription({
|
||||
user_id: user.id,
|
||||
endpoint: data.endpoint,
|
||||
p256dh: data.keys.p256dh,
|
||||
auth: data.keys.auth,
|
||||
user_agent: data.user_agent,
|
||||
platform: data.platform,
|
||||
});
|
||||
|
||||
return { success: true, msg: "Notifications are on for this device." };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions";
|
||||
import deliverToUser from "@/src/push/deliver-to-user";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Sends one notification to every device this user has subscribed, so
|
||||
* they can check that notifications arrive without waiting for a bot.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const subscriptions = await grabPushSubscriptions({
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
if (!subscriptions.length) {
|
||||
throw new AppError(
|
||||
`Turn notifications on for this device first, then try again.`,
|
||||
);
|
||||
}
|
||||
|
||||
await deliverToUser({
|
||||
user_id: user.id,
|
||||
payload: {
|
||||
title: `${SiteData["SiteName"]} notifications are on`,
|
||||
body: "This is what a bot's message will look like.",
|
||||
url: "/admin/settings/notifications",
|
||||
tag: "cobble-test",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, msg: "Test sent." };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import deletePushSubscription from "@/src/functions/backend/db/push/delete-push-subscription";
|
||||
import { PushEndpointRequestSchema } from "@/src/schemas/push-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Forgets this device. The browser also drops its own subscription first,
|
||||
* so the two never disagree for long.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const { endpoint } = parseWithSchema({
|
||||
schema: PushEndpointRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
await deletePushSubscription({ user_id: user.id, endpoint });
|
||||
|
||||
return { success: true, msg: "Notifications are off for this device." };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user