mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
234
apps/web-react/app/features/notifications/core/notify.server.ts
Normal file
234
apps/web-react/app/features/notifications/core/notify.server.ts
Normal 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) },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const NOTIFICATIONS = {
|
||||
PEEK_COUNT: 6,
|
||||
MAX_SHOWN: 100,
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
133
apps/web-react/app/features/notifications/notifications-hooks.ts
Normal file
133
apps/web-react/app/features/notifications/notifications-hooks.ts
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
115
apps/web-react/app/features/notifications/notifications-types.ts
Normal file
115
apps/web-react/app/features/notifications/notifications-types.ts
Normal 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 };
|
||||
130
apps/web-react/app/features/notifications/notifications-utils.ts
Normal file
130
apps/web-react/app/features/notifications/notifications-utils.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
|
||||
& svg {
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user