Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,360 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as NotificationFactory from "~/db/seed/factories/NotificationFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { withUserId } from "~/utils/Test";
import * as NotificationRepository from "./NotificationRepository.server";
const users = UserFactory.pool();
const seenStatusOf = async (userId: number) => {
const notifications = await NotificationRepository.findByUserId(userId);
return notifications.map(({ type, seen }) => ({ type, seen }));
};
describe("markAsSeenByType", () => {
beforeEach(async () => {
await users.create(3);
});
test("marks unseen notification of the type as seen", async () => {
await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }],
});
await NotificationRepository.markAsSeenByType({
userIds: [users.id(1)],
type: "SQ_READY_CHECK",
});
expect(await seenStatusOf(users.id(1))).toEqual([
{ type: "SQ_READY_CHECK", seen: 1 },
]);
});
test("leaves other users' notifications unseen", async () => {
await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }, { userId: users.id(2) }],
});
await NotificationRepository.markAsSeenByType({
userIds: [users.id(1)],
type: "SQ_READY_CHECK",
});
expect(await seenStatusOf(users.id(2))).toEqual([
{ type: "SQ_READY_CHECK", seen: 0 },
]);
});
test("leaves notifications of other types unseen", async () => {
await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }],
});
await NotificationFactory.create({
notification: { type: "SQ_NEW_MATCH", meta: { matchId: 1 } },
users: [{ userId: users.id(1) }],
});
await NotificationRepository.markAsSeenByType({
userIds: [users.id(1)],
type: "SQ_READY_CHECK",
});
expect(await seenStatusOf(users.id(1))).toEqual([
{ type: "SQ_NEW_MATCH", seen: 0 },
{ type: "SQ_READY_CHECK", seen: 1 },
]);
});
test("meta filter only matches notifications with the given values", async () => {
await NotificationFactory.create({
notification: { type: "SQ_NEW_MATCH", meta: { matchId: 1 } },
users: [{ userId: users.id(1) }],
});
await NotificationFactory.create({
notification: { type: "SQ_NEW_MATCH", meta: { matchId: 2 } },
users: [{ userId: users.id(1) }],
});
await NotificationRepository.markAsSeenByType({
userIds: [users.id(1)],
type: "SQ_NEW_MATCH",
meta: { matchId: 2 },
});
expect(await seenStatusOf(users.id(1))).toEqual([
{ type: "SQ_NEW_MATCH", seen: 1 },
{ type: "SQ_NEW_MATCH", seen: 0 },
]);
});
test("matches on a subset of meta keys, both string and number valued", async () => {
await NotificationFactory.create({
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 7 },
},
users: [{ userId: users.id(1) }],
});
await NotificationFactory.create({
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 2, fromUsername: "bob", scrimPostId: 7 },
},
users: [{ userId: users.id(1) }],
});
await NotificationRepository.markAsSeenByType({
userIds: [users.id(1)],
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
});
expect(await seenStatusOf(users.id(1))).toEqual([
{ type: "SCRIM_NEW_REQUEST", seen: 0 },
{ type: "SCRIM_NEW_REQUEST", seen: 1 },
]);
});
test("marks the notification as seen for every given user", async () => {
await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [
{ userId: users.id(1) },
{ userId: users.id(2) },
{ userId: users.id(3) },
],
});
await NotificationRepository.markAsSeenByType({
userIds: [users.id(1), users.id(3)],
type: "SQ_READY_CHECK",
});
expect(await seenStatusOf(users.id(1))).toEqual([
{ type: "SQ_READY_CHECK", seen: 1 },
]);
expect(await seenStatusOf(users.id(2))).toEqual([
{ type: "SQ_READY_CHECK", seen: 0 },
]);
expect(await seenStatusOf(users.id(3))).toEqual([
{ type: "SQ_READY_CHECK", seen: 1 },
]);
});
});
describe("markOwnAsSeen", () => {
beforeEach(async () => {
await users.create(2);
});
test("returns the actor's id when a notification flips to seen", async () => {
const notification = await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }],
});
const changedUserIds = await withUserId(users.id(1), () =>
NotificationRepository.markOwnAsSeen([notification.id]),
);
expect(changedUserIds).toEqual([users.id(1)]);
expect(await seenStatusOf(users.id(1))).toEqual([
{ type: "SQ_READY_CHECK", seen: 1 },
]);
});
test("returns no user ids when the notifications were already seen", async () => {
const notification = await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1), seen: 1 }],
});
const changedUserIds = await withUserId(users.id(1), () =>
NotificationRepository.markOwnAsSeen([notification.id]),
);
expect(changedUserIds).toEqual([]);
});
test("returns the actor's id once even if many notifications flip", async () => {
const notifications = await Promise.all([
NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }],
}),
NotificationFactory.create({
notification: { type: "SQ_NEW_MATCH", meta: { matchId: 1 } },
users: [{ userId: users.id(1) }],
}),
]);
const changedUserIds = await withUserId(users.id(1), () =>
NotificationRepository.markOwnAsSeen(notifications.map(({ id }) => id)),
);
expect(changedUserIds).toEqual([users.id(1)]);
});
test("leaves another user's copy of the notification unseen", async () => {
const notification = await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }, { userId: users.id(2) }],
});
await withUserId(users.id(1), () =>
NotificationRepository.markOwnAsSeen([notification.id]),
);
expect(await seenStatusOf(users.id(2))).toEqual([
{ type: "SQ_READY_CHECK", seen: 0 },
]);
});
});
describe("upsertOwnSubscription", () => {
beforeEach(async () => {
await users.create(2);
});
const subscription = (endpoint: string) => ({
endpoint,
keys: { auth: "auth", p256dh: "p256dh" },
});
test("resubscribing with the same endpoint does not duplicate the subscription", async () => {
await withUserId(users.id(1), () =>
NotificationRepository.upsertOwnSubscription(
subscription("https://push.example.com/1"),
),
);
await withUserId(users.id(1), () =>
NotificationRepository.upsertOwnSubscription(
subscription("https://push.example.com/1"),
),
);
const subscriptions =
await NotificationRepository.findAllSubscriptionsByUserIds([users.id(1)]);
expect(subscriptions).toHaveLength(1);
});
test("another user subscribing on the same browser takes over the endpoint", async () => {
await withUserId(users.id(1), () =>
NotificationRepository.upsertOwnSubscription(
subscription("https://push.example.com/1"),
),
);
await withUserId(users.id(2), () =>
NotificationRepository.upsertOwnSubscription(
subscription("https://push.example.com/1"),
),
);
expect(
await NotificationRepository.findAllSubscriptionsByUserIds([users.id(1)]),
).toHaveLength(0);
expect(
await NotificationRepository.findAllSubscriptionsByUserIds([users.id(2)]),
).toHaveLength(1);
});
test("different endpoints subscribe separately for the same user", async () => {
await withUserId(users.id(1), () =>
NotificationRepository.upsertOwnSubscription(
subscription("https://push.example.com/1"),
),
);
await withUserId(users.id(1), () =>
NotificationRepository.upsertOwnSubscription(
subscription("https://push.example.com/2"),
),
);
expect(
await NotificationRepository.findAllSubscriptionsByUserIds([users.id(1)]),
).toHaveLength(2);
});
});
describe("findUnseenSubscriptionsByNotificationId", () => {
beforeEach(async () => {
await users.create(3);
});
const subscribe = (userId: number, endpoint: string) =>
withUserId(userId, () =>
NotificationRepository.upsertOwnSubscription({
endpoint,
keys: { auth: "auth", p256dh: "p256dh" },
}),
);
const subscribedEndpointsOf = async (notificationId: number) => {
const subscriptions =
await NotificationRepository.findUnseenSubscriptionsByNotificationId(
notificationId,
);
return subscriptions.map(({ subscription }) => subscription.endpoint);
};
test("returns the subscriptions of recipients who have not seen the notification", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
await subscribe(users.id(2), "https://push.example.com/2");
const notification = await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }, { userId: users.id(2) }],
});
expect(await subscribedEndpointsOf(notification.id)).toEqual(
expect.arrayContaining([
"https://push.example.com/1",
"https://push.example.com/2",
]),
);
});
test("excludes recipients who have seen the notification", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
await subscribe(users.id(2), "https://push.example.com/2");
const notification = await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1), seen: 1 }, { userId: users.id(2) }],
});
expect(await subscribedEndpointsOf(notification.id)).toEqual([
"https://push.example.com/2",
]);
});
test("excludes subscriptions of users who did not receive the notification", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
await subscribe(users.id(3), "https://push.example.com/3");
const notification = await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }, { userId: users.id(2) }],
});
expect(await subscribedEndpointsOf(notification.id)).toEqual([
"https://push.example.com/1",
]);
});
test("returns every subscription of an unseen recipient", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
await subscribe(users.id(1), "https://push.example.com/2");
const notification = await NotificationFactory.create({
notification: { type: "SQ_READY_CHECK" },
users: [{ userId: users.id(1) }],
});
expect(await subscribedEndpointsOf(notification.id)).toHaveLength(2);
});
});

View File

@@ -0,0 +1,220 @@
import { sub } from "date-fns";
import { sql } from "kysely";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import type { NotificationSubscription } from "~/db/tables-json";
import { actorId } from "~/features/auth/core/user.server";
import { dateToDatabaseTimestamp } from "../../utils/dates";
import { NOTIFICATIONS } from "./notifications-contants";
import type { Notification } from "./notifications-types";
import { notificationMeta } from "./notifications-utils";
export function insert(
notification: Notification,
users: Array<Omit<TablesInsertable["NotificationUser"], "notificationId">>,
) {
return db.transaction().execute(async (trx) => {
const inserted = await trx
.insertInto("Notification")
.values({
type: notification.type,
pictureUrl: notification.pictureUrl,
meta: notificationMeta(notification)
? JSON.stringify(notificationMeta(notification))
: null,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("NotificationUser")
.values(
users.map(({ userId, seen }) => ({
userId,
notificationId: inserted.id,
seen: seen ?? 0,
})),
)
.execute();
return inserted;
});
}
export function findByUserId(
userId: number,
{ limit }: { limit?: number } = {},
) {
return db
.selectFrom("NotificationUser")
.innerJoin(
"Notification",
"Notification.id",
"NotificationUser.notificationId",
)
.select([
"Notification.id",
"Notification.createdAt",
"NotificationUser.seen",
"Notification.type",
"Notification.meta",
"Notification.pictureUrl",
])
.where("NotificationUser.userId", "=", userId)
.limit(limit ?? NOTIFICATIONS.MAX_SHOWN)
.orderBy("Notification.id", "desc")
.execute() as Promise<
Array<Notification & { id: number; createdAt: number; seen: number }>
>;
}
export function findAllByType<T extends Notification["type"]>(type: T) {
return db
.selectFrom("Notification")
.select(["type", "meta", "pictureUrl"])
.where("type", "=", type)
.execute() as Promise<Array<Extract<Notification, { type: T }>>>;
}
/**
* Marks the users' unseen notifications of the given type as seen, optionally
* only those whose meta matches every given key/value pair. Used to clear the
* unseen dot when the user addresses what the notification is about. Returns
* the user ids whose rows actually changed.
*
* The correlated `exists` keeps this proportional to the users' own
* notifications. A `notificationId in (select ...)` reads the same but makes
* SQLite materialize every notification of the type (json_extract'ing each one)
* before touching the user's rows, which is ~80x slower on a hot path.
*/
export async function markAsSeenByType({
userIds,
type,
meta,
}: {
userIds: number[];
type: Notification["type"];
meta?: Record<string, number | string>;
}): Promise<number[]> {
if (userIds.length === 0) return [];
const updated = await db
.updateTable("NotificationUser")
.set("seen", 1)
.where("NotificationUser.seen", "=", 0)
.where("NotificationUser.userId", "in", userIds)
.where(({ exists, selectFrom, ref }) => {
let matchingNotification = selectFrom("Notification")
.select("Notification.id")
.whereRef(
"Notification.id",
"=",
ref("NotificationUser.notificationId"),
)
.where("Notification.type", "=", type);
for (const [key, value] of Object.entries(meta ?? {})) {
matchingNotification = matchingNotification.where(
sql`json_extract("Notification"."meta", ${`$.${key}`})`,
"=",
value,
);
}
return exists(matchingNotification);
})
.returning("NotificationUser.userId")
.execute();
return R.unique(updated.map((row) => row.userId));
}
/**
* Marks the actor's notifications as seen. Returns the actor's user id in an
* array if any row actually changed (empty array otherwise), shaped for
* passing straight to `ChatSystemMessage.notifyNotificationsChanged`.
*/
export async function markOwnAsSeen(notificationIds: number[]) {
const updated = await db
.updateTable("NotificationUser")
.set("seen", 1)
.where("NotificationUser.notificationId", "in", notificationIds)
.where("NotificationUser.userId", "=", actorId())
.where("NotificationUser.seen", "=", 0)
.returning("NotificationUser.userId")
.execute();
return updated.length > 0 ? [updated[0].userId] : [];
}
export function deleteOld() {
return db
.deleteFrom("Notification")
.where(
"createdAt",
"<",
dateToDatabaseTimestamp(sub(new Date(), { days: 14 })),
)
.executeTakeFirst();
}
export function upsertOwnSubscription(subscription: NotificationSubscription) {
return db
.insertInto("NotificationUserSubscription")
.values({
userId: actorId(),
subscription: JSON.stringify(subscription),
})
.onConflict((oc) =>
// an endpoint identifies one browser; a resubscribe or another user
// logging in on the same browser takes the row over instead of
// duplicating deliveries to it
oc
.expression(sql`json_extract("subscription", '$.endpoint')`)
.doUpdateSet({
userId: actorId(),
subscription: JSON.stringify(subscription),
}),
)
.execute();
}
/**
* Finds the push subscriptions of the given notification's recipients who have
* not seen it yet. Lets the push sender skip users who already addressed what
* the notification is about during the delivery grace period.
*/
export function findUnseenSubscriptionsByNotificationId(
notificationId: number,
) {
return db
.selectFrom("NotificationUser")
.innerJoin(
"NotificationUserSubscription",
"NotificationUserSubscription.userId",
"NotificationUser.userId",
)
.select([
"NotificationUserSubscription.id",
"NotificationUserSubscription.subscription",
])
.where("NotificationUser.notificationId", "=", notificationId)
.where("NotificationUser.seen", "=", 0)
.execute();
}
export function findAllSubscriptionsByUserIds(userIds: number[]) {
return db
.selectFrom("NotificationUserSubscription")
.select(["id", "subscription"])
.where("userId", "in", userIds)
.execute();
}
export function deleteSubscriptionById(id: number) {
return db
.deleteFrom("NotificationUserSubscription")
.where("id", "=", id)
.execute();
}

View File

@@ -0,0 +1,199 @@
import * as React from "react";
import { useFetchers, useLocation, useNavigation } from "react-router";
import { useRefreshOnReconnect } from "~/features/chat/chat-hooks";
import { useChatContext } from "~/features/chat/useChatContext";
import { useBackgroundResource } from "~/hooks/useBackgroundResource";
import type { SerializeFrom } from "~/utils/remix";
import { NOTIFICATIONS_DATA_ROUTE } from "~/utils/urls";
import { resyncPushSubscription } from "./core/pushSubscription";
import type { loader } from "./routes/api.notifications";
/** Spreads out the refetches when a notification fans out to many users at once. */
const PING_REFRESH_JITTER_MS = 3_000;
const WS_DOWN_POLL_MS = 2 * 60 * 1000;
export type NotificationsData = SerializeFrom<typeof loader>["notifications"];
interface NotificationsContextValue {
notifications?: NotificationsData;
/** Refetches the notification peek, without touching the page's own loaders. */
refresh: () => void;
}
const NotificationsContext = React.createContext<NotificationsContextValue>({
refresh: () => {},
});
/**
* Serves the notification peek (bell popover + unseen dot) and keeps it fresh
* push-first: an initial fetch after mount (deliberately not part of any
* loader, so notifications never delay a page), then a refetch whenever skalop
* pings over the chat websocket that the user's notifications changed
* server-side (new notification, marked seen, resolved by an action
* elsewhere). Polling and refetch-on-activity heuristics only kick in as a
* fallback while the websocket is down.
*/
export function NotificationsProvider({
user,
children,
}: {
user?: { id: number } | null;
children: React.ReactNode;
}) {
const { data, refresh } = useBackgroundResource<SerializeFrom<typeof loader>>(
NOTIFICATIONS_DATA_ROUTE,
);
const chat = useChatContext();
const loggedIn = Boolean(user);
const readyState = chat?.readyState ?? "CLOSED";
const wsDown = loggedIn && readyState !== "CONNECTED";
React.useEffect(() => {
if (!loggedIn) return;
refresh();
void resyncPushSubscription();
}, [loggedIn, refresh]);
useRefreshOnPing({ version: chat?.notificationsVersion ?? 0, refresh });
useRefreshOnReconnect(readyState, refresh);
useRefreshOnVisible({ enabled: loggedIn, refresh });
useFallbackPoll({ enabled: wsDown, refresh });
const notifications = data?.notifications;
useFallbackRefreshOnPotentialResolution({
enabled: wsDown,
notifications,
refresh,
});
const value: NotificationsContextValue = {
notifications,
refresh,
};
return (
<NotificationsContext.Provider value={value}>
{children}
</NotificationsContext.Provider>
);
}
/** The user's notification peek; `notifications` is `undefined` until the first fetch lands. */
export function useNotificationsData() {
return React.useContext(NotificationsContext);
}
function useRefreshOnPing({
version,
refresh,
}: {
version: number;
refresh: () => void;
}) {
React.useEffect(() => {
if (version === 0) return;
// jittered so a notification sent to a whole tournament's worth of users
// does not make every connected client refetch in the same instant; a
// follow-up ping inside the window replaces the pending refetch
const timeout = setTimeout(refresh, Math.random() * PING_REFRESH_JITTER_MS);
return () => clearTimeout(timeout);
}, [version, refresh]);
}
/** Refetches when the tab becomes visible again (e.g. waking from sleep). */
function useRefreshOnVisible({
enabled,
refresh,
}: {
enabled: boolean;
refresh: () => void;
}) {
React.useEffect(() => {
if (!enabled) return;
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
refresh();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [enabled, refresh]);
}
function useFallbackPoll({
enabled,
refresh,
}: {
enabled: boolean;
refresh: () => void;
}) {
React.useEffect(() => {
if (!enabled) return;
const interval = setInterval(refresh, WS_DOWN_POLL_MS);
return () => clearInterval(interval);
}, [enabled, refresh]);
}
/**
* Without the websocket there is no ping when something the user did resolves
* an unseen notification, so fall back to refetching after anything that may
* have: a navigation (loaders mark notifications seen when the user views the
* page a notification points at) or a settled action submission (actions mark
* them seen when the user addresses the thing itself). Only fires while an
* unseen notification exists, so it usually adds no server load even then.
*/
function useFallbackRefreshOnPotentialResolution({
enabled,
notifications,
refresh,
}: {
enabled: boolean;
notifications: NotificationsData;
refresh: () => void;
}) {
const location = useLocation();
const navigation = useNavigation();
const fetchers = useFetchers();
const hasUnseen =
(enabled && notifications?.some((notification) => !notification.seen)) ??
false;
const submitting =
navigation.state === "submitting" ||
fetchers.some((fetcher) => fetcher.state === "submitting");
const allIdle =
navigation.state === "idle" &&
fetchers.every((fetcher) => fetcher.state === "idle");
const refreshOnIdleRef = React.useRef(false);
if (submitting && hasUnseen) {
refreshOnIdleRef.current = true;
}
const prevLocationKeyRef = React.useRef(location.key);
React.useEffect(() => {
if (prevLocationKeyRef.current !== location.key) {
prevLocationKeyRef.current = location.key;
if (hasUnseen) {
refresh();
}
return;
}
if (allIdle && refreshOnIdleRef.current) {
refreshOnIdleRef.current = false;
refresh();
}
}, [location.key, allIdle, hasUnseen, refresh]);
}

View File

@@ -0,0 +1,65 @@
.itemDivider {
margin-inline: var(--s-3);
border-width: 0.5px;
border-color: var(--color-bg-high);
}
.imageContainer {
place-self: center;
grid-area: image;
border-radius: var(--radius-avatar);
width: 30px;
height: 30px;
background-color: var(--color-bg-high);
display: grid;
place-items: center;
position: relative;
}
.item {
padding: var(--s-1) var(--s-3);
display: grid;
grid-template-areas: "image header" "image timestamp";
grid-template-columns: 30px 1fr;
column-gap: var(--s-2);
padding-block: var(--s-3);
color: var(--color-text);
&:hover .imageContainer {
outline: 3px solid var(--color-bg-higher);
}
&:focus-within .imageContainer {
outline: 3px solid var(--color-accent-low);
}
}
.unseenDot {
background-color: var(--color-text-accent);
border-radius: 100%;
width: 8px;
height: 8px;
position: absolute;
top: -1px;
left: -1px;
outline: 2px solid var(--color-bg);
}
.itemImage {
width: 30px;
height: 30px;
border-radius: var(--radius-avatar);
}
.itemHeader {
grid-area: header;
font-size: var(--font-sm);
font-weight: var(--weight-semi);
}
.timestamp {
grid-area: timestamp;
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-body);
}

View File

@@ -0,0 +1,99 @@
import { formatDistance } from "date-fns";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Image } from "~/components/Image";
import type { LoaderNotification } from "~/components/layout/NotificationPopover";
import {
notificationLink,
notificationMeta,
notificationNavIcon,
} from "~/features/notifications/notifications-utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl } from "~/utils/urls";
import styles from "./NotificationList.module.css";
export function NotificationsList({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
export function NotificationItem({
notification,
onClose,
}: {
notification: LoaderNotification;
onClose?: () => void;
}) {
const { t } = useTranslation(["common"]);
return (
<Link
to={notificationLink(notification)}
className={styles.item}
data-testid="notification-item"
onClick={onClose}
>
<NotificationImage notification={notification}>
{!notification.seen ? (
<div
className={styles.unseenDot}
data-testid="notification-unseen-dot"
/>
) : null}
</NotificationImage>
<div className={styles.itemHeader}>
{t(
`common:notifications.text.${notification.type}`,
notificationMeta(notification),
)}
</div>
<div className={styles.timestamp}>
{formatDistance(
databaseTimestampToDate(notification.createdAt),
new Date(),
{
addSuffix: true,
},
)}
</div>
</Link>
);
}
export function NotificationItemDivider() {
return <hr className={styles.itemDivider} />;
}
function NotificationImage({
notification,
children,
}: {
notification: LoaderNotification;
children: React.ReactNode;
}) {
if (notification.pictureUrl) {
return (
<div className={styles.imageContainer}>
{children}
<img
src={notification.pictureUrl}
alt="Notification"
className={styles.itemImage}
width={124}
height={124}
/>
</div>
);
}
return (
<div className={styles.imageContainer}>
{children}
<Image
path={navIconUrl(notificationNavIcon(notification.type))}
width={24}
height={24}
alt=""
/>
</div>
);
}

View File

@@ -0,0 +1,502 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { withUserId } from "~/utils/Test";
import { APP_ICON_URL } from "~/utils/urls";
import * as NotificationRepository from "../NotificationRepository.server";
import { notificationMeta } from "../notifications-utils";
import {
clearSentNotificationsForTesting,
notify,
PUSH_NOTIFICATION_GRACE_PERIOD_MS,
} from "./notify.server";
const users = UserFactory.pool();
const { mockSendNotification, mockWebPushEnabled } = vi.hoisted(() => ({
mockSendNotification: vi.fn(),
mockWebPushEnabled: { value: false },
}));
vi.mock("./webPush.server", () => ({
get webPushEnabled() {
return mockWebPushEnabled.value;
},
default: {
sendNotification: mockSendNotification,
},
}));
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
notifyNotificationsChanged: vi.fn(),
}));
describe("notify()", () => {
beforeEach(async () => {
await users.create(20);
clearSentNotificationsForTesting();
vi.mocked(ChatSystemMessage.notifyNotificationsChanged).mockClear();
});
test("different recipients receive same notification", async () => {
await notify({
userIds: [users.id(1), users.id(2)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
await notify({
userIds: [users.id(3), users.id(4)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
const user1Notifications = await NotificationRepository.findByUserId(
users.id(1),
);
const user2Notifications = await NotificationRepository.findByUserId(
users.id(2),
);
const user3Notifications = await NotificationRepository.findByUserId(
users.id(3),
);
const user4Notifications = await NotificationRepository.findByUserId(
users.id(4),
);
expect(user1Notifications).toHaveLength(1);
expect(user2Notifications).toHaveLength(1);
expect(user3Notifications).toHaveLength(1);
expect(user4Notifications).toHaveLength(1);
expect(user1Notifications[0].type).toBe("SCRIM_NEW_REQUEST");
expect(notificationMeta(user1Notifications[0])).toEqual({
fromUserId: 1,
fromUsername: "alice",
scrimPostId: 1,
});
});
test("same recipients and notification deduplicates", async () => {
await notify({
userIds: [users.id(5), users.id(6)],
notification: {
type: "BADGE_ADDED",
meta: { badgeName: "Test", badgeId: 1 },
},
});
await notify({
userIds: [users.id(5), users.id(6)],
notification: {
type: "BADGE_ADDED",
meta: { badgeName: "Test", badgeId: 1 },
},
});
const user5Notifications = await NotificationRepository.findByUserId(
users.id(5),
);
const user6Notifications = await NotificationRepository.findByUserId(
users.id(6),
);
expect(user5Notifications).toHaveLength(1);
expect(user6Notifications).toHaveLength(1);
});
test("pings recipients' websockets once per delivered notification", async () => {
await notify({
userIds: [users.id(1), users.id(2)],
notification: {
type: "BADGE_ADDED",
meta: { badgeName: "Test", badgeId: 1 },
},
});
expect(ChatSystemMessage.notifyNotificationsChanged).toHaveBeenCalledWith([
users.id(1),
users.id(2),
]);
// deduplicated resend delivers nothing, so it should not ping either
await notify({
userIds: [users.id(1), users.id(2)],
notification: {
type: "BADGE_ADDED",
meta: { badgeName: "Test", badgeId: 1 },
},
});
expect(ChatSystemMessage.notifyNotificationsChanged).toHaveBeenCalledTimes(
1,
);
});
test("identical notification is delivered again when repeated a day later", async () => {
vi.useFakeTimers();
try {
await notify({
userIds: [users.id(5)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
vi.advanceTimersByTime(24 * 60 * 60 * 1000);
await notify({
userIds: [users.id(5)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
} finally {
vi.useRealTimers();
}
const notifications = await NotificationRepository.findByUserId(
users.id(5),
);
expect(notifications).toHaveLength(2);
});
test("user ID order doesn't affect deduplication", async () => {
await notify({
userIds: [users.id(7), users.id(8), users.id(9)],
notification: {
type: "SEASON_STARTED",
meta: { seasonNth: 1 },
},
});
await notify({
userIds: [users.id(9), users.id(7), users.id(8)],
notification: {
type: "SEASON_STARTED",
meta: { seasonNth: 1 },
},
});
const user7Notifications = await NotificationRepository.findByUserId(
users.id(7),
);
const user8Notifications = await NotificationRepository.findByUserId(
users.id(8),
);
const user9Notifications = await NotificationRepository.findByUserId(
users.id(9),
);
expect(user7Notifications).toHaveLength(1);
expect(user8Notifications).toHaveLength(1);
expect(user9Notifications).toHaveLength(1);
});
test("bulk notifications (>10 users) bypass deduplication", async () => {
const userIds = users.ids(11);
await notify({
userIds,
notification: {
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId: 1, tournamentName: "Test Tournament" },
},
});
await notify({
userIds,
notification: {
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId: 1, tournamentName: "Test Tournament" },
},
});
const user1Notifications = await NotificationRepository.findByUserId(
users.id(1),
);
const user11Notifications = await NotificationRepository.findByUserId(
users.id(11),
);
expect(user1Notifications).toHaveLength(2);
expect(user11Notifications).toHaveLength(2);
});
test("different notification types don't deduplicate", async () => {
await notify({
userIds: [users.id(10), users.id(11)],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: 1, opponentTeamName: "Alpha" },
},
});
await notify({
userIds: [users.id(10), users.id(11)],
notification: {
type: "SCRIM_CANCELED",
meta: { id: 1, opponentTeamName: "Alpha" },
},
});
const user10Notifications = await NotificationRepository.findByUserId(
users.id(10),
);
const user11Notifications = await NotificationRepository.findByUserId(
users.id(11),
);
expect(user10Notifications).toHaveLength(2);
expect(user11Notifications).toHaveLength(2);
const types = user10Notifications.map((n) => n.type).sort();
expect(types).toEqual(["SCRIM_CANCELED", "SCRIM_SCHEDULED"]);
});
test("different notification meta don't deduplicate", async () => {
await notify({
userIds: [users.id(12), users.id(13)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 2, fromUsername: "bob", scrimPostId: 1 },
},
});
await notify({
userIds: [users.id(12), users.id(13)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 3, fromUsername: "charlie", scrimPostId: 1 },
},
});
const user12Notifications = await NotificationRepository.findByUserId(
users.id(12),
);
const user13Notifications = await NotificationRepository.findByUserId(
users.id(13),
);
expect(user12Notifications).toHaveLength(2);
expect(user13Notifications).toHaveLength(2);
const metas = user12Notifications.map(notificationMeta);
expect(metas).toContainEqual({
fromUserId: 2,
fromUsername: "bob",
scrimPostId: 1,
});
expect(metas).toContainEqual({
fromUserId: 3,
fromUsername: "charlie",
scrimPostId: 1,
});
});
test("duplicate user IDs in input array are deduplicated", async () => {
await notify({
userIds: [
users.id(14),
users.id(14),
users.id(15),
users.id(15),
users.id(15),
],
notification: {
type: "PLUS_VOTING_STARTED",
meta: { seasonNth: 2 },
},
});
const user14Notifications = await NotificationRepository.findByUserId(
users.id(14),
);
const user15Notifications = await NotificationRepository.findByUserId(
users.id(15),
);
expect(user14Notifications).toHaveLength(1);
expect(user15Notifications).toHaveLength(1);
});
});
describe("notify() - web push notifications", () => {
beforeEach(async () => {
await users.create(20);
clearSentNotificationsForTesting();
mockSendNotification.mockClear();
mockWebPushEnabled.value = false;
});
const subscribe = (userId: number, endpoint: string) =>
withUserId(userId, () =>
NotificationRepository.upsertOwnSubscription({
endpoint,
keys: { auth: "test-auth-key", p256dh: "test-p256dh-key" },
}),
);
const pushedEndpoints = () =>
mockSendNotification.mock.calls.map(
([subscription]) => subscription.endpoint,
);
const notifyAndElapseGracePeriod = async (
args: Parameters<typeof notify>[0],
duringGracePeriod?: () => Promise<unknown>,
) => {
vi.useFakeTimers();
try {
await notify(args);
await duringGracePeriod?.();
await vi.advanceTimersByTimeAsync(PUSH_NOTIFICATION_GRACE_PERIOD_MS);
} finally {
vi.useRealTimers();
}
};
test("sends web push notification after the grace period", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
mockWebPushEnabled.value = true;
vi.useFakeTimers();
try {
await notify({
userIds: [users.id(1)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
expect(mockSendNotification).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(PUSH_NOTIFICATION_GRACE_PERIOD_MS);
} finally {
vi.useRealTimers();
}
expect(mockSendNotification).toHaveBeenCalledTimes(1);
expect(mockSendNotification).toHaveBeenCalledWith(
{
endpoint: "https://push.example.com/1",
keys: { auth: "test-auth-key", p256dh: "test-p256dh-key" },
},
expect.any(String),
{ urgency: "high" },
);
const callArgs = mockSendNotification.mock.calls[0][1];
const payload = JSON.parse(callArgs);
expect(payload.title).toBe("New Scrim Request");
expect(payload.body).toBe("alice requested a scrim");
expect(payload.data.url).toBe("/scrims");
expect(payload.icon).toBe(APP_ICON_URL);
});
test("sends web push to multiple subscriptions", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
await subscribe(users.id(2), "https://push.example.com/2");
mockWebPushEnabled.value = true;
await notifyAndElapseGracePeriod({
userIds: [users.id(1), users.id(2)],
notification: {
type: "BADGE_ADDED",
meta: { badgeName: "Test", badgeId: 1 },
},
});
expect(mockSendNotification).toHaveBeenCalledTimes(2);
expect(pushedEndpoints()).toEqual(
expect.arrayContaining([
"https://push.example.com/1",
"https://push.example.com/2",
]),
);
});
test("does not send web push when webPushEnabled is false", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
await notifyAndElapseGracePeriod({
userIds: [users.id(1)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
expect(mockSendNotification).not.toHaveBeenCalled();
});
test("skips the push for a user who saw the notification during the grace period", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
await subscribe(users.id(2), "https://push.example.com/2");
mockWebPushEnabled.value = true;
await notifyAndElapseGracePeriod(
{
userIds: [users.id(1), users.id(2)],
notification: {
type: "SQ_NEW_MATCH",
meta: { matchId: 1 },
},
},
() =>
NotificationRepository.markAsSeenByType({
userIds: [users.id(1)],
type: "SQ_NEW_MATCH",
meta: { matchId: 1 },
}),
);
expect(pushedEndpoints()).toEqual(["https://push.example.com/2"]);
});
test("skips the push for users who had the notification seen by default", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
await subscribe(users.id(2), "https://push.example.com/2");
mockWebPushEnabled.value = true;
await notifyAndElapseGracePeriod({
userIds: [users.id(1), users.id(2)],
defaultSeenUserIds: [users.id(1)],
notification: {
type: "SQ_NEW_MATCH",
meta: { matchId: 1 },
},
});
expect(pushedEndpoints()).toEqual(["https://push.example.com/2"]);
});
test("includes opponent team name for scrim notifications", async () => {
await subscribe(users.id(1), "https://push.example.com/1");
mockWebPushEnabled.value = true;
await notifyAndElapseGracePeriod({
userIds: [users.id(1)],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: 1, opponentTeamName: "Sendou's pickup" },
},
});
expect(mockSendNotification).toHaveBeenCalledTimes(1);
const callArgs = mockSendNotification.mock.calls[0][1];
const payload = JSON.parse(callArgs);
expect(payload.title).toBe("Scrim Scheduled");
expect(payload.body).toBe("New scrim scheduled vs. Sendou's pickup");
});
});

View File

@@ -0,0 +1,234 @@
import type { TFunction } from "i18next";
import pLimit from "p-limit";
import { type Urgency, WebPushError } from "web-push";
import type { NotificationSubscription } from "~/db/tables-json";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { APP_ICON_URL } from "~/utils/urls";
import { getFixedTForLanguage } from "../../../modules/i18n/i18next.server";
import { logger } from "../../../utils/logger";
import * as NotificationRepository from "../NotificationRepository.server";
import type { Notification } from "../notifications-types";
import { notificationLink, notificationMeta } from "../notifications-utils";
import webPush, { webPushEnabled } from "./webPush.server";
const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
SQ_ADDED_TO_GROUP: "high",
SQ_NEW_MATCH: "high",
SQ_READY_CHECK: "high",
TO_ADDED_TO_TEAM: "normal",
TO_BRACKET_STARTED: "high",
TO_CHECK_IN_OPENED: "high",
TO_TEST_CREATED: "normal",
TO_LIKE_RECEIVED: "high",
TO_LIKE_ACCEPTED: "high",
BADGE_ADDED: "normal",
BADGE_MANAGER_ADDED: "normal",
TROPHY_SUBMITTED: "normal",
TROPHY_SUBMISSION_ACCEPTED: "normal",
TROPHY_SUBMISSION_DECLINED: "normal",
PLUS_VOTING_STARTED: "normal",
PLUS_SUGGESTION_ADDED: "normal",
TAGGED_TO_ART: "normal",
SEASON_STARTED: "normal",
SCRIM_NEW_REQUEST: "high",
SCRIM_SCHEDULED: "high",
SCRIM_CANCELED: "high",
SCRIM_STARTING_SOON: "high",
SCRIM_AUTO_DELETED: "normal",
COMMISSIONS_CLOSED: "normal",
FRIEND_REQUEST_RECEIVED: "normal",
};
/** How long a push notification is held back before sending. Anything marking the notification as seen during this window (the user addressing what it is about, opening the notification list, `defaultSeenUserIds`) cancels the push for that user. */
export const PUSH_NOTIFICATION_GRACE_PERIOD_MS = 15 * 1000;
/**
* Create notifications both in the database and send push notifications to users (if enabled).
*
* Pushes go out after {@link PUSH_NOTIFICATION_GRACE_PERIOD_MS} and only to
* users whose notification is still unseen at that point, so users who already
* saw the event happen in-app are not pushed about it.
*/
export async function notify({
userIds,
notification,
defaultSeenUserIds,
skipPushGracePeriod,
}: {
/** Array of user ids to notify */
userIds: Array<number>;
/** Array of user ids that should have the notification marked as seen by default */
defaultSeenUserIds?: Array<number>;
/** Notification to send (same for all users) */
notification: Notification;
/** Send push notifications right away and await them (used by the send-test-notification script) */
skipPushGracePeriod?: boolean;
}) {
if (userIds.length === 0) {
return;
}
const dededuplicatedUserIds = Array.from(new Set(userIds));
if (isNotificationAlreadySent(notification, dededuplicatedUserIds)) {
return;
}
let notificationId: number;
try {
const inserted = await NotificationRepository.insert(
notification,
dededuplicatedUserIds.map((userId) => ({
userId,
seen: defaultSeenUserIds?.includes(userId) ? 1 : 0,
})),
);
notificationId = inserted.id;
ChatSystemMessage.notifyNotificationsChanged(dededuplicatedUserIds);
} catch (e) {
logger.error("Failed to notify users", e);
return;
}
if (skipPushGracePeriod) {
await sendPushNotificationsToUnseen({ notificationId, notification });
} else {
schedulePushNotifications({ notificationId, notification });
}
}
function schedulePushNotifications({
notificationId,
notification,
}: {
notificationId: number;
notification: Notification;
}) {
if (!webPushEnabled) return;
setTimeout(() => {
sendPushNotificationsToUnseen({ notificationId, notification }).catch(
(err) => logger.error("Failed to send push notifications", err),
);
}, PUSH_NOTIFICATION_GRACE_PERIOD_MS).unref();
}
async function sendPushNotificationsToUnseen({
notificationId,
notification,
}: {
notificationId: number;
notification: Notification;
}) {
if (!webPushEnabled) return;
const subscriptions =
await NotificationRepository.findUnseenSubscriptionsByNotificationId(
notificationId,
);
if (subscriptions.length === 0) return;
const t = await getFixedTForLanguage("en-US", ["common"]);
const limit = pLimit(50);
await Promise.all(
subscriptions.map(({ id, subscription }) =>
limit(() =>
sendPushNotification({
subscription,
subscriptionId: id,
notification,
t,
}),
),
),
);
}
const SENT_NOTIFICATION_TTL_MS = 1000 * 60 * 60;
const sentNotifications = new Map<string, number>();
export function clearSentNotificationsForTesting() {
sentNotifications.clear();
}
// deduplicates notifications as a failsafe & anti-abuse mechanism; entries
// expire so a legitimately repeated identical notification (e.g. the same team
// requesting a scrim again weeks later) still gets delivered
function isNotificationAlreadySent(
notification: Notification,
userIds: Array<number>,
) {
// e2e tests should not be affected by this
if (IS_E2E_TEST_RUN) {
return false;
}
// bulk notifications are typically not something you can repeat
if (userIds.length > 10) {
return false;
}
const sortedUserIds = [...userIds].sort((a, b) => a - b).join(",");
const key = `${notification.type}-${JSON.stringify(notificationMeta(notification))}-${sortedUserIds}`;
const sentAt = sentNotifications.get(key);
if (sentAt && Date.now() - sentAt < SENT_NOTIFICATION_TTL_MS) {
return true;
}
sentNotifications.set(key, Date.now());
if (sentNotifications.size > 10_000) {
sentNotifications.clear();
}
return false;
}
async function sendPushNotification({
subscription,
subscriptionId,
notification,
t,
}: {
subscription: NotificationSubscription;
subscriptionId: number;
notification: Notification;
t: TFunction<["common"], undefined>;
}) {
try {
await webPush.sendNotification(
subscription,
JSON.stringify(pushNotificationOptions(notification, t)),
{ urgency: NOTIFICATION_URGENCY[notification.type] },
);
} catch (err) {
if (!(err instanceof WebPushError)) {
logger.error("Failed to send push notification (unknown error)", err);
// if we get "Not Found" or "Gone" we should delete the subscription as it is expired or no longer valid
} else if (err.statusCode === 404 || err.statusCode === 410) {
await NotificationRepository.deleteSubscriptionById(subscriptionId);
} else {
logger.error("Failed to send push notification", err);
}
}
}
function pushNotificationOptions(
notification: Notification,
t: TFunction<["common"], undefined>,
): Parameters<ServiceWorkerRegistration["showNotification"]>[1] & {
title: string;
} {
return {
title: t(`common:notifications.title.${notification.type}`),
body: t(
`common:notifications.text.${notification.type}`,
notificationMeta(notification),
),
icon: notification.pictureUrl ?? APP_ICON_URL,
data: { url: notificationLink(notification) },
};
}

View File

@@ -0,0 +1,65 @@
import { Config } from "~/config";
import { logger } from "~/utils/logger";
import { NOTIFICATIONS_SUBSCRIBE_ROUTE } from "~/utils/urls";
/** Whether this browser supports push notifications. Only call after hydration. */
export function isPushSupported() {
return (
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
/**
* Subscribes this browser to push notifications (reusing the existing
* subscription if one is live) and syncs it to the server. Throws if
* subscribing or the server sync fails, meaning the server might not know
* where to deliver push notifications for this browser.
*/
export async function subscribeToPush() {
const registration = await navigator.serviceWorker.register("/sw-2.js");
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: Config.vapid.publicKey,
}));
const response = await fetch(NOTIFICATIONS_SUBSCRIBE_ROUTE, {
method: "post",
body: JSON.stringify(subscription),
headers: { "content-type": "application/json" },
});
if (!response.ok) {
throw new Error(
`Syncing push subscription to server failed: ${response.status}`,
);
}
}
/**
* The live push subscription of this browser, or null when there is none
* (never subscribed, expired, or revoked). Only call after hydration.
*/
export async function findPushSubscription() {
const registration = await navigator.serviceWorker.getRegistration();
return (await registration?.pushManager.getSubscription()) ?? null;
}
/**
* Self-heals push delivery for a browser that has already opted in: if the
* subscription expired, was revoked by the browser, or the server lost/deleted
* its copy (e.g. after the push service returned 410 Gone), this resubscribes
* and re-syncs so notifications keep arriving. Safe to call on every app
* load; a failure is swallowed as the next load retries.
*/
export async function resyncPushSubscription() {
if (!isPushSupported() || Notification.permission !== "granted") return;
try {
await subscribeToPush();
} catch (err) {
logger.error("Failed to resync push subscription", err);
}
}

View File

@@ -0,0 +1,92 @@
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { logger } from "~/utils/logger";
import * as NotificationRepository from "../NotificationRepository.server";
import type { Notification } from "../notifications-types";
type NotificationOfType<T extends Notification["type"]> = Extract<
Notification,
{ type: T }
>;
type MetaFilter<T extends Notification["type"]> =
NotificationOfType<T> extends { meta: infer M } ? Partial<M> : undefined;
/**
* What resolves each notification type beyond opening the notification list.
* `null` means nothing does: the notification is informational, or resolving it
* would cost a query on a hot path for little gain (TO_ADDED_TO_TEAM,
* TO_TEST_CREATED). Exhaustive on purpose, so a new notification type has to
* pick a side, and only the types with a trigger can be resolved.
*/
const RESOLUTION_TRIGGERS = {
SQ_ADDED_TO_GROUP: "visits a SendouQ group page (preparing/looking)",
SQ_READY_CHECK:
"responds to the ready check, or it ends (match created or the check expired)",
SQ_NEW_MATCH: "visits the match page",
TO_ADDED_TO_TEAM: null,
TO_BRACKET_STARTED: "visits the tournament's brackets page",
TO_CHECK_IN_OPENED: "their team checks in (by a member or the organizer)",
TO_TEST_CREATED: null,
TO_LIKE_RECEIVED:
"visits the tournament's LFG page, or their group accepts a like",
TO_LIKE_ACCEPTED: "visits the tournament's LFG page",
BADGE_ADDED: null,
BADGE_MANAGER_ADDED: null,
TROPHY_SUBMITTED:
"a reviewer approves/declines the submission or it gets deleted (a lone approval that is not yet enough resolves the approver's own)",
TROPHY_SUBMISSION_ACCEPTED: null,
TROPHY_SUBMISSION_DECLINED: null,
PLUS_VOTING_STARTED: "casts their votes",
PLUS_SUGGESTION_ADDED: "visits the suggestions page of the tier",
TAGGED_TO_ART: null,
SEASON_STARTED: null,
SCRIM_NEW_REQUEST:
"a request for the post is accepted (settling the post), the request is canceled by its sender, or the post is deleted",
SCRIM_SCHEDULED: "visits the scrim's page, or the scrim gets canceled",
SCRIM_CANCELED: null,
SCRIM_STARTING_SOON: "visits the scrim's page, or the scrim gets canceled",
SCRIM_AUTO_DELETED: null,
COMMISSIONS_CLOSED: null,
FRIEND_REQUEST_RECEIVED:
"accepts or declines the request, or the sender cancels it",
} as const satisfies Record<Notification["type"], string | null>;
type ResolvableNotificationType = {
[T in Notification["type"]]: (typeof RESOLUTION_TRIGGERS)[T] extends null
? never
: T;
}[Notification["type"]];
/**
* Marks the users' unseen notifications of the given type as seen because they
* addressed the thing the notification is about, so the unseen dot only shows
* for notifications that still need the user's attention. Never throws; a
* failed resolution only logs since the caller's action/loader matters more.
*
* See `RESOLUTION_TRIGGERS` for what resolves each type.
*/
export async function resolveNotifications<
T extends ResolvableNotificationType,
>({
userIds,
type,
meta,
}: {
/** Users whose notifications got addressed */
userIds: Array<number>;
/** Notification type to resolve */
type: T;
/** Only notifications whose meta matches every given key/value pair are resolved (e.g. `{ tournamentId }`) */
meta?: MetaFilter<T>;
}) {
try {
const changedUserIds = await NotificationRepository.markAsSeenByType({
userIds,
type,
meta,
});
ChatSystemMessage.notifyNotificationsChanged(changedUserIds);
} catch (err) {
logger.error("Failed to resolve notifications", err);
}
}

View File

@@ -0,0 +1,23 @@
import webPush from "web-push";
import { Config } from "~/config";
import { ServerConfig } from "~/config.server";
import { logger } from "~/utils/logger";
export let webPushEnabled = false;
if (
ServerConfig.vapid.email &&
Config.vapid.publicKey &&
ServerConfig.vapid.privateKey
) {
webPush.setVapidDetails(
ServerConfig.vapid.email,
Config.vapid.publicKey,
ServerConfig.vapid.privateKey,
);
webPushEnabled = true;
} else {
logger.info("VAPID env vars not set, push notifications will not work");
}
export default webPush;

View File

@@ -0,0 +1,10 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as NotificationRepository from "../NotificationRepository.server";
export const loader = async () => {
const user = requireUser();
return {
notifications: await NotificationRepository.findByUserId(user.id),
};
};

View File

@@ -0,0 +1,4 @@
export const NOTIFICATIONS = {
PEEK_COUNT: 6,
MAX_SHOWN: 100,
};

View File

@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import { useShowUnseenDot } from "./notifications-hooks";
const GRACE_MS = 10_000;
function UnseenDot({
notifications,
}: {
notifications: Array<{ createdAt: number; seen: number }>;
}) {
const showDot = useShowUnseenDot(notifications);
return <div data-testid="dot">{showDot ? "shown" : "hidden"}</div>;
}
const dotStatus = (screen: Awaited<ReturnType<typeof render>>) =>
screen.getByTestId("dot").element().textContent;
/** Database timestamp (seconds) for a moment relative to the fake clock. */
const createdAt = (offsetMs: number) =>
Math.floor((Date.now() + offsetMs) / 1000);
/**
* Runs the fake clock forward and lets React paint what the fired timers
* changed. React schedules its render through a MessageChannel, which fake
* timers do not control, so a message of our own posted afterwards is what
* tells us the render already happened.
*/
const advanceTimers = async (ms: number) => {
await vi.advanceTimersByTimeAsync(ms);
return new Promise<void>((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => resolve();
channel.port2.postMessage(null);
});
};
describe("useShowUnseenDot", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("shows the dot right away for a notification predating the session", async () => {
const screen = await render(
<UnseenDot
notifications={[{ createdAt: createdAt(-60_000), seen: 0 }]}
/>,
);
expect(dotStatus(screen)).toBe("shown");
});
test("never shows the dot when every notification is seen", async () => {
const screen = await render(
<UnseenDot
notifications={[{ createdAt: createdAt(-60_000), seen: 1 }]}
/>,
);
expect(dotStatus(screen)).toBe("hidden");
await advanceTimers(GRACE_MS * 2);
expect(dotStatus(screen)).toBe("hidden");
});
test("holds the dot back until the grace period passes for one born mid-session", async () => {
const screen = await render(
<UnseenDot notifications={[{ createdAt: createdAt(1_000), seen: 0 }]} />,
);
expect(dotStatus(screen)).toBe("hidden");
await advanceTimers(GRACE_MS);
expect(dotStatus(screen)).toBe("hidden");
await advanceTimers(2_000);
expect(dotStatus(screen)).toBe("shown");
});
test("shows the dot as soon as the earliest of many notifications is past the grace period", async () => {
const screen = await render(
<UnseenDot
notifications={[
{ createdAt: createdAt(30_000), seen: 0 },
{ createdAt: createdAt(1_000), seen: 0 },
]}
/>,
);
expect(dotStatus(screen)).toBe("hidden");
await advanceTimers(GRACE_MS + 2_000);
expect(dotStatus(screen)).toBe("shown");
});
});

View File

@@ -0,0 +1,133 @@
import * as React from "react";
import { useFetcher } from "react-router";
import { NOTIFICATIONS_MARK_AS_SEEN_ROUTE } from "~/utils/urls";
import { useNotificationsData } from "./NotificationsProvider";
export function useMarkNotificationsAsSeen(unseenIds: number[]) {
const fetcher = useFetcher();
const { refresh } = useNotificationsData();
const submittedIdsRef = React.useRef(new Set<number>());
const refreshPendingRef = React.useRef(false);
const { submit } = fetcher;
React.useEffect(() => {
// a submit while one is in flight would abort it; ids arriving mid-flight
// get submitted when the fetcher returns to idle
if (fetcher.state !== "idle") return;
// the action's skalop ping also triggers a refetch, but only for clients
// with a live websocket; refetching here keeps the dot clearing promptly
// for the tab that did the marking either way
if (refreshPendingRef.current) {
refreshPendingRef.current = false;
refresh();
}
const idsToSubmit = unseenIds.filter(
(id) => !submittedIdsRef.current.has(id),
);
if (idsToSubmit.length === 0) return;
for (const id of idsToSubmit) {
submittedIdsRef.current.add(id);
}
refreshPendingRef.current = true;
submit(
{ notificationIds: idsToSubmit },
{
method: "post",
encType: "application/json",
action: NOTIFICATIONS_MARK_AS_SEEN_ROUTE,
},
);
}, [submit, unseenIds, fetcher.state, refresh]);
}
const UNSEEN_DOT_GRACE_MS = 10_000;
/**
* Whether the bell should show its unseen dot. An unseen notification born
* while the session is already open only counts once it has stayed unseen past
* a short grace period: one about something the user is already on their way
* to (e.g. a SendouQ match that just started, with the redirect to the match
* page a second away) resolves itself right after, and the dot flashing for
* it would be false signal. Notifications predating the session show the dot
* right away — anything that was going to resolve them (a loader of the page
* being landed on) already ran before the first notifications fetch.
*/
export function useShowUnseenDot(
notifications: Array<{ createdAt: number; seen: number }> | undefined,
) {
// time lives in state (only advanced by the timer below) because reading
// Date.now() during render would be frozen by the React Compiler's memoization
const [mountedAt] = React.useState(() => Date.now());
const [now, setNow] = React.useState(mountedAt);
const dotShowTimes =
notifications
?.filter((notification) => !notification.seen)
.map((notification) => {
const createdAtMs = notification.createdAt * 1000;
return createdAtMs <= mountedAt
? mountedAt
: createdAtMs + UNSEEN_DOT_GRACE_MS;
}) ?? [];
const showDot = dotShowTimes.some((showTime) => showTime <= now);
const nextShowTime =
!showDot && dotShowTimes.length > 0 ? Math.min(...dotShowTimes) : null;
React.useEffect(() => {
if (nextShowTime === null) return;
const timeout = setTimeout(
() => setNow(Date.now()),
Math.max(0, nextShowTime - Date.now()) + 100,
);
return () => clearTimeout(timeout);
}, [nextShowTime]);
return showDot;
}
/**
* Ids of the notifications to show an unseen dot for, keeping the dot for as
* long as the list stays open. Opening the list marks its notifications as
* seen right away so the bell stops claiming there is something new, and this
* keeps the reader from losing track of which ones those were.
*/
export function useStickyUnseenIds(
notifications: Array<{ id: number; seen: number }>,
) {
const [unseenIds, setUnseenIds] = React.useState(
() => new Set(unseenIdsOf(notifications)),
);
const [prevNotifications, setPrevNotifications] =
React.useState(notifications);
if (prevNotifications !== notifications) {
setPrevNotifications(notifications);
setUnseenIds((prevUnseenIds) => {
const newUnseenIds = new Set(prevUnseenIds);
for (const id of unseenIdsOf(notifications)) {
newUnseenIds.add(id);
}
// optimize render by not updating state if nothing changed
if (newUnseenIds.size === prevUnseenIds.size) return prevUnseenIds;
return newUnseenIds;
});
}
return unseenIds;
}
function unseenIdsOf(notifications: Array<{ id: number; seen: number }>) {
return notifications
.filter((notification) => !notification.seen)
.map((notification) => notification.id);
}

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
import { id } from "~/utils/zod";
import { NOTIFICATIONS } from "./notifications-contants";
export const markAsSeenActionSchema = z.object({
notificationIds: z.array(id).min(1).max(NOTIFICATIONS.MAX_SHOWN),
});
export const subscribeSchema = z.object({
endpoint: z.string().url().startsWith("https://").max(2048),
keys: z.object({
auth: z.string().max(1024),
p256dh: z.string().max(1024),
}),
});

View File

@@ -0,0 +1,115 @@
export type Notification =
| NotificationItem<
"SQ_ADDED_TO_GROUP",
{
adderUsername: string;
}
>
| NotificationItem<"SQ_READY_CHECK">
| NotificationItem<
"SQ_NEW_MATCH",
{
matchId: number;
}
>
| NotificationItem<
"TO_ADDED_TO_TEAM",
{
tournamentId: number;
tournamentName: string;
adderUsername: string;
teamName: string;
tournamentTeamId: number;
}
>
| NotificationItem<
"TO_BRACKET_STARTED",
{
tournamentId: number;
bracketIdx: number;
bracketName: string;
tournamentName: string;
}
>
| NotificationItem<
"TO_CHECK_IN_OPENED",
{
tournamentId: number;
tournamentName: string;
}
>
| NotificationItem<
"TO_TEST_CREATED",
{
tournamentId: number;
tournamentName: string;
}
>
| NotificationItem<"BADGE_ADDED", { badgeName: string; badgeId: number }>
| NotificationItem<
"BADGE_MANAGER_ADDED",
{ badgeName: string; badgeId: number }
>
| NotificationItem<
"TROPHY_SUBMITTED",
{ trophyName: string; submitterUsername: string }
>
| NotificationItem<
"TROPHY_SUBMISSION_ACCEPTED",
{ trophyName: string; trophyId: number }
>
| NotificationItem<"TROPHY_SUBMISSION_DECLINED", { trophyName: string }>
| NotificationItem<
"PLUS_VOTING_STARTED",
{
seasonNth: number;
}
>
| NotificationItem<"PLUS_SUGGESTION_ADDED", { tier: number }>
| NotificationItem<
"TAGGED_TO_ART",
{ adderUsername: string; adderDiscordId: string; artId: number }
>
| NotificationItem<"SEASON_STARTED", { seasonNth: number }>
| NotificationItem<
"SCRIM_NEW_REQUEST",
{ fromUserId: number; fromUsername: string; scrimPostId: number }
>
| NotificationItem<
"SCRIM_SCHEDULED",
{ id: number; opponentTeamName: string }
>
| NotificationItem<"SCRIM_CANCELED", { id: number; opponentTeamName: string }>
| NotificationItem<
"SCRIM_STARTING_SOON",
{ id: number; opponentTeamName: string }
>
| NotificationItem<"SCRIM_AUTO_DELETED", { at: number }>
| NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }>
| NotificationItem<
"FRIEND_REQUEST_RECEIVED",
{ senderId: number; senderUsername: string }
>
| NotificationItem<
"TO_LIKE_RECEIVED",
{
tournamentId: number;
tournamentName: string;
likerUsername: string;
}
>
| NotificationItem<
"TO_LIKE_ACCEPTED",
{
tournamentId: number;
tournamentName: string;
accepterUsername: string;
}
>;
type NotificationItem<
T extends string,
M extends Record<string, number | string> | undefined = undefined,
> = M extends undefined
? { type: T; pictureUrl?: string }
: { type: T; meta: M; pictureUrl?: string };

View File

@@ -0,0 +1,130 @@
import { userArtPage } from "~/features/art/art-urls";
import { plusSuggestionPage } from "~/features/plus-suggestions/plus-suggestions-urls";
import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls";
import { assertUnreachable } from "~/utils/types";
import {
badgePage,
FRIENDS_PAGE,
NEW_TROPHY_PAGE,
PLUS_VOTING_PAGE,
SENDOUQ_PAGE,
SENDOUQ_READY_PAGE,
scrimPage,
scrimsPage,
sendouQMatchPage,
tournamentRegisterPage,
tournamentSubsPage,
tournamentTeamPage,
trophyPage,
userEditProfilePage,
} from "~/utils/urls";
import type { Notification } from "./notifications-types";
/** Values the notification's title and text interpolate. Some notification types have none. */
export const notificationMeta = (notification: Notification) =>
"meta" in notification ? notification.meta : undefined;
export const notificationNavIcon = (type: Notification["type"]) => {
switch (type) {
case "BADGE_ADDED":
case "BADGE_MANAGER_ADDED":
return "badges";
case "TROPHY_SUBMITTED":
case "TROPHY_SUBMISSION_ACCEPTED":
case "TROPHY_SUBMISSION_DECLINED":
return "trophies";
case "PLUS_SUGGESTION_ADDED":
case "PLUS_VOTING_STARTED":
return "plus";
case "SQ_ADDED_TO_GROUP":
case "SQ_NEW_MATCH":
case "SQ_READY_CHECK":
case "SEASON_STARTED":
return "sendouq";
case "TAGGED_TO_ART":
case "COMMISSIONS_CLOSED":
return "art";
case "TO_ADDED_TO_TEAM":
case "TO_BRACKET_STARTED":
case "TO_CHECK_IN_OPENED":
case "TO_TEST_CREATED":
case "TO_LIKE_RECEIVED":
case "TO_LIKE_ACCEPTED":
return "medal";
case "SCRIM_NEW_REQUEST":
case "SCRIM_SCHEDULED":
case "SCRIM_CANCELED":
case "SCRIM_STARTING_SOON":
case "SCRIM_AUTO_DELETED":
return "scrims";
case "FRIEND_REQUEST_RECEIVED":
return "sendou_love";
default:
assertUnreachable(type);
}
};
export const notificationLink = (notification: Notification) => {
switch (notification.type) {
case "BADGE_ADDED":
return badgePage(notification.meta.badgeId);
case "BADGE_MANAGER_ADDED":
return badgePage(notification.meta.badgeId);
case "TROPHY_SUBMITTED":
case "TROPHY_SUBMISSION_DECLINED":
return NEW_TROPHY_PAGE;
case "TROPHY_SUBMISSION_ACCEPTED":
return trophyPage(notification.meta.trophyId);
case "PLUS_SUGGESTION_ADDED":
return plusSuggestionPage({ tier: notification.meta.tier });
case "PLUS_VOTING_STARTED":
return PLUS_VOTING_PAGE;
case "SEASON_STARTED":
case "SQ_ADDED_TO_GROUP":
return SENDOUQ_PAGE;
case "SQ_NEW_MATCH":
return sendouQMatchPage(notification.meta.matchId);
case "SQ_READY_CHECK":
return SENDOUQ_READY_PAGE;
case "TAGGED_TO_ART":
return userArtPage(
{ discordId: notification.meta.adderDiscordId },
"MADE-BY",
notification.meta.artId,
);
case "TO_ADDED_TO_TEAM":
return tournamentTeamPage({
tournamentId: notification.meta.tournamentId,
tournamentTeamId: notification.meta.tournamentTeamId,
});
case "TO_BRACKET_STARTED":
return tournamentBracketsPage({
tournamentId: notification.meta.tournamentId,
bracketIdx: notification.meta.bracketIdx,
});
case "TO_TEST_CREATED":
case "TO_CHECK_IN_OPENED":
return tournamentRegisterPage(notification.meta.tournamentId);
case "SCRIM_NEW_REQUEST":
case "SCRIM_AUTO_DELETED": {
return scrimsPage();
}
case "SCRIM_CANCELED":
case "SCRIM_SCHEDULED":
case "SCRIM_STARTING_SOON": {
return scrimPage(notification.meta.id);
}
case "COMMISSIONS_CLOSED": {
return userEditProfilePage({ discordId: notification.meta.discordId });
}
case "FRIEND_REQUEST_RECEIVED": {
return FRIENDS_PAGE;
}
case "TO_LIKE_RECEIVED":
case "TO_LIKE_ACCEPTED": {
return tournamentSubsPage(notification.meta.tournamentId);
}
default:
assertUnreachable(notification);
}
};

View File

@@ -0,0 +1,20 @@
import { getUser } from "~/features/auth/core/user.server";
import * as NotificationRepository from "../NotificationRepository.server";
import { NOTIFICATIONS } from "../notifications-contants";
/**
* The notification peek shown in the bell popover. Fetched by
* `NotificationsProvider` whenever skalop pings that the user's notifications
* changed, instead of being polled with the rest of the app shell data.
*/
export const loader = async () => {
const user = getUser();
return {
notifications: user
? await NotificationRepository.findByUserId(user.id, {
limit: NOTIFICATIONS.PEEK_COUNT,
})
: undefined,
};
};

View File

@@ -0,0 +1,9 @@
.header {
display: flex;
align-items: center;
gap: var(--s-3);
& svg {
width: 24px;
}
}

View File

@@ -0,0 +1,20 @@
import type { ActionFunctionArgs } from "react-router";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { parseRequestPayload } from "~/utils/remix.server";
import * as NotificationRepository from "../NotificationRepository.server";
import { markAsSeenActionSchema } from "../notifications-schemas";
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestPayload({
request,
schema: markAsSeenActionSchema,
});
const changedUserIds = await NotificationRepository.markOwnAsSeen(
data.notificationIds,
);
// so the unseen dot clears on the user's other open tabs and devices too
ChatSystemMessage.notifyNotificationsChanged(changedUserIds);
return null;
};

View File

@@ -0,0 +1,18 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestPayload } from "~/utils/remix.server";
import * as NotificationRepository from "../NotificationRepository.server";
import { subscribeSchema } from "../notifications-schemas";
export const action = async ({ request }: ActionFunctionArgs) => {
requireUser();
const data = await parseRequestPayload({
request,
schema: subscribeSchema,
});
await NotificationRepository.upsertOwnSubscription(data);
return null;
};

View File

@@ -0,0 +1,76 @@
import { Bell } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, type MetaFunction, useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { metaTags } from "../../../utils/remix";
import { SETTINGS_PAGE } from "../../../utils/urls";
import {
NotificationItem,
NotificationItemDivider,
NotificationsList,
} from "../components/NotificationList";
import { loader } from "../loaders/notifications.server";
import {
useMarkNotificationsAsSeen,
useStickyUnseenIds,
} from "../notifications-hooks";
export { loader };
import styles from "./notifications.module.css";
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Notifications",
location: args.location,
});
};
export default function NotificationsPage() {
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const unseenIds = useStickyUnseenIds(data.notifications);
const unSeenIdsArr = React.useMemo(() => Array.from(unseenIds), [unseenIds]);
useMarkNotificationsAsSeen(unSeenIdsArr);
return (
<Main className="stack md">
<div className="stack horizontal justify-between items-center flex-wrap">
<h2 className={styles.header}>
<Bell /> {t("common:notifications.title")}
</h2>
<Link className="text-xs" to={SETTINGS_PAGE}>
{t("common:notifications.managePush")}
</Link>
</div>
{data.notifications.length === 0 ? (
<div className="layout__notifications__no-notifications">
{t("common:notifications.empty")}
</div>
) : (
<NotificationsList>
{data.notifications.map((notification, i) => (
<React.Fragment key={notification.id}>
<NotificationItem
key={notification.id}
notification={{
...notification,
seen: Number(!unseenIds.has(notification.id)),
}}
/>
{i !== data.notifications.length - 1 && (
<NotificationItemDivider />
)}
</React.Fragment>
))}
</NotificationsList>
)}
<div className="text-xs text-lighter mt-6">
{t("common:notifications.fullList.explanation")}
</div>
</Main>
);
}