Smarter notification mark as read (#3330)

This commit is contained in:
Kalle
2026-08-09 15:10:48 +03:00
committed by GitHub
parent 17ddc17f7b
commit d88c027169
59 changed files with 1384 additions and 114 deletions

View File

@@ -56,11 +56,10 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
const [activePanel, setActivePanel] = React.useState<PanelType>("closed");
const previousPanelRef = React.useRef<PanelType>("closed");
const user = useUser();
const { unseenIds } = useNotifications();
const { showUnseenDot } = useNotifications();
const chatContext = useChatContext();
const layoutSize = useLayoutSize();
const hasUnseenNotifications = unseenIds.length > 0;
const hasFriendInSendouQ =
sidebarData?.friends.some((f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL) ??
false;
@@ -157,7 +156,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
activePanel={activePanel}
onTabPress={handleTabPress}
isLoggedIn={Boolean(user)}
hasUnseenNotifications={hasUnseenNotifications}
hasUnseenNotifications={showUnseenDot}
hasFriendInSendouQ={hasFriendInSendouQ}
unseenFriendRequests={unseenFriendRequests}
/>

View File

@@ -1,9 +1,15 @@
import clsx from "clsx";
import styles from "./NotificationDot.module.css";
export function NotificationDot({ className }: { className?: string }) {
export function NotificationDot({
className,
testId,
}: {
className?: string;
testId?: string;
}) {
return (
<span className={clsx(styles.dotWrapper, className)}>
<span className={clsx(styles.dotWrapper, className)} data-testid={testId}>
<span className={styles.pulse} />
<span className={styles.dot} />
</span>

View File

@@ -12,10 +12,11 @@
min-height: 200px;
}
.topContainer {
.header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-sm);
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
& svg {
@@ -26,13 +27,6 @@
}
}
.header {
display: flex;
align-items: center;
font-size: var(--font-sm);
gap: var(--s-2);
}
.noNotifications {
display: grid;
place-items: center;

View File

@@ -1,27 +1,30 @@
import { Bell, ChevronRight, RefreshCcw } from "lucide-react";
import { Bell, ChevronRight } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useLayoutData } from "~/features/layout/LayoutDataProvider";
import {
NotificationItem,
NotificationItemDivider,
NotificationsList,
} from "~/features/notifications/components/NotificationList";
import {
type NotificationsData,
useNotificationsData,
} from "~/features/notifications/NotificationsProvider";
import { NOTIFICATIONS } from "~/features/notifications/notifications-contants";
import type { RootLoaderData } from "~/root";
import { NOTIFICATIONS_URL } from "~/utils/urls";
import { useMarkNotificationsAsSeen } from "../../features/notifications/notifications-hooks";
import { SendouButton } from "../elements/Button";
import {
useMarkNotificationsAsSeen,
useShowUnseenDot,
useStickyUnseenIds,
} from "../../features/notifications/notifications-hooks";
import styles from "./NotificationPopover.module.css";
export type LoaderNotification = NonNullable<
RootLoaderData["notifications"]
>[number];
export type LoaderNotification = NonNullable<NotificationsData>[number];
export function useNotifications() {
const { notifications } = useLayoutData();
const { notifications } = useNotificationsData();
const unseenIds = React.useMemo(
() =>
@@ -31,7 +34,9 @@ export function useNotifications() {
[notifications],
);
return { notifications, unseenIds };
const showUnseenDot = useShowUnseenDot(notifications);
return { notifications, unseenIds, showUnseenDot };
}
export function NotificationContent({
@@ -44,24 +49,15 @@ export function NotificationContent({
onClose?: () => void;
}) {
const { t } = useTranslation(["common"]);
const { refresh, isRefreshing } = useLayoutData();
const stickyUnseenIds = useStickyUnseenIds(notifications);
useMarkNotificationsAsSeen(unseenIds);
return (
<>
<div className={styles.topContainer}>
<h2 className={styles.header}>
<Bell /> {t("common:notifications.title")}
</h2>
<SendouButton
icon={<RefreshCcw />}
shape="circle"
variant="minimal"
onPress={refresh}
isDisabled={isRefreshing}
/>
</div>
<h2 className={styles.header}>
<Bell /> {t("common:notifications.title")}
</h2>
<hr className={styles.divider} />
{notifications.length === 0 ? (
<div className={styles.noNotifications}>
@@ -73,7 +69,10 @@ export function NotificationContent({
<React.Fragment key={notification.id}>
<NotificationItem
key={notification.id}
notification={notification}
notification={{
...notification,
seen: Number(!stickyUnseenIds.has(notification.id)),
}}
onClose={onClose}
/>
{i !== notifications.length - 1 && <NotificationItemDivider />}

View File

@@ -275,7 +275,7 @@ export function Layout({
}
const user = useUser();
const { unseenIds } = useNotifications();
const { showUnseenDot } = useNotifications();
const { sidebar: sidebarData } = useLayoutData();
const events = sidebarData?.events ?? [];
const friends = sidebarData?.friends ?? [];
@@ -421,7 +421,7 @@ export function Layout({
>
<SideNavCollapseButton
className={styles.sideNavModalTrigger}
showNotificationDot={!sideNavModalOpen && unseenIds.length > 0}
showNotificationDot={!sideNavModalOpen && showUnseenDot}
badgeCount={!sideNavModalOpen ? unseenFriendRequests : 0}
testId="sidenav-modal-trigger"
/>
@@ -458,7 +458,7 @@ export function Layout({
<SideNavCollapseButton
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
className={styles.sideNavCollapseButton}
showNotificationDot={sideNavCollapsed && unseenIds.length > 0}
showNotificationDot={sideNavCollapsed && showUnseenDot}
badgeCount={sideNavCollapsed ? unseenFriendRequests : 0}
testId="sidenav-collapse-button"
/>
@@ -671,7 +671,7 @@ function SideNavUserPanel() {
const { t } = useTranslation();
const location = useLocation();
const user = useUser();
const { notifications, unseenIds } = useNotifications();
const { notifications, unseenIds, showUnseenDot } = useNotifications();
if (user) {
return (
@@ -688,9 +688,10 @@ function SideNavUserPanel() {
className={sideNavStyles.sideNavFooterNotification}
key={location.pathname}
>
{unseenIds.length > 0 ? (
{showUnseenDot ? (
<NotificationDot
className={sideNavStyles.sideNavFooterUnseenDot}
testId="notifications-bell-dot"
/>
) : null}
<SendouPopover

View File

@@ -119,6 +119,7 @@ function ChatProviderInner({
{},
);
const clearChatLabels = React.useCallback(() => setChatLabels({}), []);
const [notificationsVersion, setNotificationsVersion] = React.useState(0);
const ws = React.useRef<WebSocket>(undefined);
@@ -191,6 +192,13 @@ function ChatProviderInner({
return;
}
// Notifications changed server-side; handled before the fallthrough below
// so a contentless ping is never treated as a chat message
if (parsed.event === "NOTIFICATIONS_CHANGED") {
setNotificationsVersion((version) => version + 1);
return;
}
// CHAT_HISTORY response (also returned by SUBSCRIBE with metadata)
if (parsed.event === "CHAT_HISTORY" && Array.isArray(parsed.messages)) {
logger.debug(
@@ -487,6 +495,7 @@ function ChatProviderInner({
unreadCounts,
totalUnreadCount,
readyState,
notificationsVersion,
chatUsers,
chatOpen,
setChatOpen,
@@ -510,6 +519,7 @@ function ChatProviderInner({
unreadCounts,
totalUnreadCount,
readyState,
notificationsVersion,
chatUsers,
chatOpen,
activeRooms,

View File

@@ -118,6 +118,28 @@ function postMessages(fullMessages: ChatMessage[]) {
}).catch(logSkalpError("sendMessage"));
}
/**
* Tells skalop to send a contentless "your notifications changed" ping to the
* users' websocket connections, prompting their clients to refetch. Fire and
* forget like the other system messages; a lost ping only delays the refetch.
*/
export function notifyNotificationsChanged(userIds: number[]) {
if (systemMessagesDisabled) return;
if (userIds.length === 0) return;
return void fetch(ServerConfig.skalop.systemMessageUrl!, {
method: "POST",
body: JSON.stringify({
action: "notifyUsers",
userIds,
}),
headers: [
[SKALOP_TOKEN_HEADER_NAME, ServerConfig.skalop.token!],
["Content-Type", "application/json"],
],
}).catch(logSkalpError("notifyUsers"));
}
export function removeRoom(chatCode: string) {
if (systemMessagesDisabled) return;

View File

@@ -49,6 +49,11 @@ export interface ChatContextValue {
unreadCounts: Record<string, number>;
totalUnreadCount: number;
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
/**
* Bumps every time skalop pings that the user's notifications changed
* server-side. Carries no data on purpose; watchers react by refetching.
*/
notificationsVersion: number;
chatUsers: Record<number, ChatUser>;
chatOpen: boolean;
setChatOpen: (open: boolean) => void;

View File

@@ -158,7 +158,8 @@ export async function deleteFriendRequest({
.deleteFrom("FriendRequest")
.where("FriendRequest.id", "=", id)
.where("FriendRequest.senderId", "=", senderId)
.execute();
.returning("FriendRequest.receiverId")
.executeTakeFirst();
}
export function deleteOldPendingRequests() {
@@ -276,7 +277,8 @@ export async function findFriendRequestByIdAndReceiver({
}) {
return db
.selectFrom("FriendRequest")
.select("FriendRequest.senderId")
.innerJoin("User", "User.id", "FriendRequest.senderId")
.select(["FriendRequest.senderId", "User.username as senderUsername"])
.where("FriendRequest.id", "=", id)
.where("FriendRequest.receiverId", "=", receiverId)
.executeTakeFirst();

View File

@@ -1,6 +1,7 @@
import type { ActionFunction } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import * as FriendRepository from "../FriendRepository.server";
@@ -38,18 +39,27 @@ export const action: ActionFunction = async ({ request }) => {
userIds: [result.data.userId],
notification: {
type: "FRIEND_REQUEST_RECEIVED",
meta: { senderUsername: user.username },
meta: { senderId: user.id, senderUsername: user.username },
},
});
break;
}
case "CANCEL_REQUEST": {
await FriendRepository.deleteFriendRequest({
const deleted = await FriendRepository.deleteFriendRequest({
id: result.data.friendRequestId,
senderId: user.id,
});
if (deleted) {
// the receiver no longer has a request to respond to
await resolveNotifications({
userIds: [deleted.receiverId],
type: "FRIEND_REQUEST_RECEIVED",
meta: { senderId: user.id },
});
}
break;
}
case "DELETE_FRIEND": {
@@ -71,14 +81,33 @@ export const action: ActionFunction = async ({ request }) => {
friendRequestId: result.data.friendRequestId,
});
await resolveNotifications({
userIds: [user.id],
type: "FRIEND_REQUEST_RECEIVED",
meta: { senderId: friendRequest.senderId },
});
break;
}
case "DECLINE_REQUEST": {
const friendRequest =
await FriendRepository.findFriendRequestByIdAndReceiver({
id: result.data.friendRequestId,
receiverId: user.id,
});
if (!friendRequest) break;
await FriendRepository.deleteFriendRequestByReceiver({
id: result.data.friendRequestId,
receiverId: user.id,
});
await resolveNotifications({
userIds: [user.id],
type: "FRIEND_REQUEST_RECEIVED",
meta: { senderId: friendRequest.senderId },
});
break;
}
}

View File

@@ -11,7 +11,6 @@ interface LayoutData {
/** `null` when the server has no session, `undefined` when it has not said. */
loggedInUserId?: number | null;
sidebar?: RootLoaderData["sidebar"];
notifications?: RootLoaderData["notifications"];
buildCommit?: string;
}
@@ -69,6 +68,9 @@ export function LayoutDataProvider({
};
}, [load]);
// stable so effects that refresh after a mutation don't re-run every render
const refresh = React.useCallback(() => load(LAYOUT_DATA_ROUTE), [load]);
const newest = useNewestOf(data, fetcher.data);
useReloadOnNewDeploy(newest.buildCommit ?? "");
@@ -79,7 +81,7 @@ export function LayoutDataProvider({
const value: LayoutDataContextValue = {
...newest,
refresh: () => load(LAYOUT_DATA_ROUTE),
refresh,
isRefreshing: state !== "idle",
};
@@ -91,8 +93,8 @@ export function LayoutDataProvider({
}
/**
* App shell data (sidebar, notification peek, build commit), fresher than the
* root loader whenever a poll has landed since the last root revalidation.
* App shell data (sidebar, build commit), fresher than the root loader
* whenever a poll has landed since the last root revalidation.
*/
export function useLayoutData() {
return React.useContext(LayoutDataContext);

View File

@@ -1,23 +1,18 @@
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
import { NOTIFICATIONS } from "~/features/notifications/notifications-contants";
import { resolveSidebarData } from "~/features/sidebar/core/sidebar.server";
import { GIT_COMMIT } from "~/utils/git-commit";
/**
* The parts of the app shell that go stale on their own while a page sits open.
* Served both by the root loader and by the resource route `LayoutDataProvider`
* polls, so the two payloads can't drift apart.
* polls, so the two payloads can't drift apart. Notifications are not part of
* this: they live in their own resource route refetched when skalop pings that
* they changed (see `NotificationsProvider`).
*/
export async function resolveLayoutData(user: AuthenticatedUser | undefined) {
return {
loggedInUserId: user?.id ?? null,
sidebar: await resolveSidebarData(user?.id ?? null),
notifications: user
? await NotificationRepository.findByUserId(user.id, {
limit: NOTIFICATIONS.PEEK_COUNT,
})
: undefined,
buildCommit: GIT_COMMIT,
};
}

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyNotificationsChanged: vi.fn(),
removeRoom: vi.fn(),
setMetadata: vi.fn(),
}));

View File

@@ -0,0 +1,216 @@
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 },
]);
});
});

View File

@@ -1,4 +1,6 @@
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";
@@ -75,13 +77,75 @@ export function findAllByType<T extends Notification["type"]>(type: T) {
.execute() as Promise<Array<Extract<Notification, { type: T }>>>;
}
export function markOwnAsSeen(notificationIds: number[]) {
return db
/**
* 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() {

View File

@@ -0,0 +1,227 @@
import * as React from "react";
import {
useFetcher,
useFetchers,
useLocation,
useNavigation,
} from "react-router";
import { useChatContext } from "~/features/chat/useChatContext";
import type { SerializeFrom } from "~/utils/remix";
import { NOTIFICATIONS_DATA_ROUTE } from "~/utils/urls";
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 fetcher = useFetcher<typeof loader>();
const chat = useChatContext();
const { load } = fetcher;
const loggedIn = Boolean(user);
const readyState = chat?.readyState ?? "CLOSED";
const wsDown = loggedIn && readyState !== "CONNECTED";
// stable so effects that refresh after a mutation don't re-run every render
const refresh = React.useCallback(
() => load(NOTIFICATIONS_DATA_ROUTE),
[load],
);
React.useEffect(() => {
if (!loggedIn) return;
refresh();
}, [loggedIn, refresh]);
useRefreshOnPing({ version: chat?.notificationsVersion ?? 0, refresh });
useRefreshOnReconnect({ readyState, refresh });
useRefreshOnVisible({ enabled: loggedIn, refresh });
useFallbackPoll({ enabled: wsDown, refresh });
const notifications = fetcher.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 after the websocket comes back up, covering pings missed while down. */
function useRefreshOnReconnect({
readyState,
refresh,
}: {
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
refresh: () => void;
}) {
const hadConnectedRef = React.useRef(false);
React.useEffect(() => {
if (readyState !== "CONNECTED") return;
if (!hadConnectedRef.current) {
hadConnectedRef.current = true;
return;
}
refresh();
}, [readyState, 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

@@ -33,7 +33,12 @@ export function NotificationItem({
onClick={onClose}
>
<NotificationImage notification={notification}>
{!notification.seen ? <div className={styles.unseenDot} /> : null}
{!notification.seen ? (
<div
className={styles.unseenDot}
data-testid="notification-unseen-dot"
/>
) : null}
</NotificationImage>
<div className={styles.itemHeader}>
{t(

View File

@@ -1,5 +1,6 @@
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 { APP_ICON_URL } from "~/utils/urls";
import * as NotificationRepository from "../NotificationRepository.server";
import { notificationMeta } from "../notifications-utils";
@@ -21,10 +22,15 @@ vi.mock("./webPush.server", () => ({
},
}));
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 () => {
@@ -32,7 +38,7 @@ describe("notify()", () => {
userIds: [users.id(1), users.id(2)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
@@ -40,7 +46,7 @@ describe("notify()", () => {
userIds: [users.id(3), users.id(4)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
@@ -64,7 +70,9 @@ describe("notify()", () => {
expect(user1Notifications[0].type).toBe("SCRIM_NEW_REQUEST");
expect(notificationMeta(user1Notifications[0])).toEqual({
fromUserId: 1,
fromUsername: "alice",
scrimPostId: 1,
});
});
@@ -96,6 +104,34 @@ describe("notify()", () => {
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 {
@@ -103,7 +139,7 @@ describe("notify()", () => {
userIds: [users.id(5)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
@@ -113,7 +149,7 @@ describe("notify()", () => {
userIds: [users.id(5)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
} finally {
@@ -225,7 +261,7 @@ describe("notify()", () => {
userIds: [users.id(12), users.id(13)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "bob" },
meta: { fromUserId: 2, fromUsername: "bob", scrimPostId: 1 },
},
});
@@ -233,7 +269,7 @@ describe("notify()", () => {
userIds: [users.id(12), users.id(13)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "charlie" },
meta: { fromUserId: 3, fromUsername: "charlie", scrimPostId: 1 },
},
});
@@ -248,8 +284,16 @@ describe("notify()", () => {
expect(user13Notifications).toHaveLength(2);
const metas = user12Notifications.map(notificationMeta);
expect(metas).toContainEqual({ fromUsername: "bob" });
expect(metas).toContainEqual({ fromUsername: "charlie" });
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 () => {
@@ -312,7 +356,7 @@ describe("notify() - web push notifications", () => {
userIds: [users.id(1)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});
@@ -408,7 +452,7 @@ describe("notify() - web push notifications", () => {
userIds: [users.id(1)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
meta: { fromUserId: 1, fromUsername: "alice", scrimPostId: 1 },
},
});

View File

@@ -2,6 +2,7 @@ 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";
@@ -72,6 +73,7 @@ export async function notify({
seen: defaultSeenUserIds?.includes(userId) ? 1 : 0,
})),
);
ChatSystemMessage.notifyNotificationsChanged(dededuplicatedUserIds);
} catch (e) {
logger.error("Failed to notify users", e);
}

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,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

@@ -1,10 +1,13 @@
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(() => {
@@ -12,6 +15,14 @@ export function useMarkNotificationsAsSeen(unseenIds: number[]) {
// 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),
);
@@ -20,6 +31,7 @@ export function useMarkNotificationsAsSeen(unseenIds: number[]) {
for (const id of idsToSubmit) {
submittedIdsRef.current.add(id);
}
refreshPendingRef.current = true;
submit(
{ notificationIds: idsToSubmit },
@@ -29,5 +41,93 @@ export function useMarkNotificationsAsSeen(unseenIds: number[]) {
action: NOTIFICATIONS_MARK_AS_SEEN_ROUTE,
},
);
}, [submit, unseenIds, fetcher.state]);
}, [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

@@ -71,7 +71,10 @@ export type Notification =
{ adderUsername: string; adderDiscordId: string; artId: number }
>
| NotificationItem<"SEASON_STARTED", { seasonNth: number }>
| NotificationItem<"SCRIM_NEW_REQUEST", { fromUsername: string }>
| NotificationItem<
"SCRIM_NEW_REQUEST",
{ fromUserId: number; fromUsername: string; scrimPostId: number }
>
| NotificationItem<
"SCRIM_SCHEDULED",
{ id: number; opponentTeamName: string }
@@ -83,7 +86,10 @@ export type Notification =
>
| NotificationItem<"SCRIM_AUTO_DELETED", { at: number }>
| NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }>
| NotificationItem<"FRIEND_REQUEST_RECEIVED", { senderUsername: string }>
| NotificationItem<
"FRIEND_REQUEST_RECEIVED",
{ senderId: number; senderUsername: string }
>
| NotificationItem<
"TO_LIKE_RECEIVED",
{

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

@@ -1,4 +1,5 @@
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";
@@ -9,7 +10,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
schema: markAsSeenActionSchema,
});
await NotificationRepository.markOwnAsSeen(data.notificationIds);
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

@@ -11,7 +11,10 @@ import {
NotificationsList,
} from "../components/NotificationList";
import { loader } from "../loaders/notifications.server";
import { useMarkNotificationsAsSeen } from "../notifications-hooks";
import {
useMarkNotificationsAsSeen,
useStickyUnseenIds,
} from "../notifications-hooks";
export { loader };
@@ -27,36 +30,7 @@ export const meta: MetaFunction = (args) => {
export default function NotificationsPage() {
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const [unseenIds, setUnseenIds] = React.useState(
() =>
new Set(
data.notifications
.filter((notification) => !notification.seen)
.map((notification) => notification.id),
),
);
const [prevNotifications, setPrevNotifications] = React.useState(
data.notifications,
);
// persist unseen dots for the duration of the page being viewed
if (prevNotifications !== data.notifications) {
setPrevNotifications(data.notifications);
setUnseenIds((prevUnseenIds) => {
const newUnseenIds = new Set(prevUnseenIds);
for (const notification of data.notifications) {
if (!notification.seen) {
newUnseenIds.add(notification.id);
}
}
// optimize render by not updating state if nothing changed
if (newUnseenIds.size === prevUnseenIds.size) return prevUnseenIds;
return newUnseenIds;
});
}
const unseenIds = useStickyUnseenIds(data.notifications);
const unSeenIdsArr = React.useMemo(() => Array.from(unseenIds), [unseenIds]);

View File

@@ -1,5 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
import {
nextNonCompletedVoting,
@@ -29,6 +30,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = getUser();
const monthYear = rangeToMonthYear(nextVotingRange);
if (user) {
await resolveNotifications({
userIds: [user.id],
type: "PLUS_SUGGESTION_ADDED",
meta: { tier: shownTier },
});
}
const [suggestions, summary] = await Promise.all([
PlusSuggestionRepository.findAllByMonth({ ...monthYear, tier: shownTier }),
PlusSuggestionRepository.findMonthSummary({

View File

@@ -1,5 +1,6 @@
import type { ActionFunction } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import type { PlusVoteFromFE } from "~/features/plus-voting/core";
import {
nextNonCompletedVoting,
@@ -63,6 +64,11 @@ export const action: ActionFunction = async ({ request }) => {
})),
);
await resolveNotifications({
userIds: [user.id],
type: "PLUS_VOTING_STARTED",
});
return null;
};

View File

@@ -1,6 +1,7 @@
import type { ActionFunctionArgs } from "react-router";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { parseFormData } from "~/form/parse.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
@@ -74,6 +75,22 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
meta: { id: post.id, opponentTeamName: postTeamName },
},
});
// the canceled scrim is no longer happening
const participantIds = [
...post.users.map((m) => m.id),
...acceptedRequest.users.map((m) => m.id),
];
await resolveNotifications({
userIds: participantIds,
type: "SCRIM_SCHEDULED",
meta: { id: post.id },
});
await resolveNotifications({
userIds: participantIds,
type: "SCRIM_STARTING_SOON",
meta: { id: post.id },
});
}
break;

View File

@@ -7,6 +7,7 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { datePlaceholder } from "~/features/chat/chat-utils";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { requirePermission } from "~/modules/permissions/guards.server";
@@ -58,6 +59,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await ScrimPostRepository.deleteById(post.id);
// requests to the deleted post can no longer be accepted
await resolveNotifications({
userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
type: "SCRIM_NEW_REQUEST",
meta: { scrimPostId: post.id },
});
break;
}
case "NEW_REQUEST": {
@@ -129,7 +137,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
notification: {
type: "SCRIM_NEW_REQUEST",
meta: {
fromUserId: user.id,
fromUsername: user.username,
scrimPostId: post.id,
},
},
});
@@ -155,6 +165,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
throw error;
}
// accepting one request settles the post, the rest can no longer be accepted
await resolveNotifications({
userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
type: "SCRIM_NEW_REQUEST",
meta: { scrimPostId: post.id },
});
const fullPost = await ScrimPostRepository.findById(post.id);
if (fullPost?.chatCode) {
ChatSystemMessage.setMetadata({
@@ -230,6 +247,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
meta: { at: removed.startsAt },
},
});
await resolveNotifications({
userIds: removed.memberIds,
type: "SCRIM_NEW_REQUEST",
meta: { scrimPostId: removed.id },
});
}
} catch (error) {
logger.error("Failed to auto-cancel overlapping scrims", error);
@@ -239,7 +261,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
break;
}
case "CANCEL_REQUEST": {
const { request } = await findRequest({
const { post, request } = await findRequest({
requestId: data.scrimPostRequestId,
});
requirePermission(request, "CANCEL");
@@ -251,6 +273,18 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await ScrimPostRepository.deleteRequest(data.scrimPostRequestId);
const requestOwner = request.users.find((u) => u.isOwner);
if (requestOwner) {
await resolveNotifications({
userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
type: "SCRIM_NEW_REQUEST",
meta: {
scrimPostId: post.id,
fromUserId: requestOwner.id,
},
});
}
break;
}
case "PERSIST_SCRIM_FILTERS": {

View File

@@ -1,5 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import { chatAccessible } from "~/features/chat/chat-utils";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
@@ -29,6 +30,17 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
throw new Response(null, { status: 403 });
}
await resolveNotifications({
userIds: [user.id],
type: "SCRIM_SCHEDULED",
meta: { id: post.id },
});
await resolveNotifications({
userIds: [user.id],
type: "SCRIM_STARTING_SOON",
meta: { id: post.id },
});
const participantIds = Scrim.participantIdsListFromAccepted(post);
const anyUserPrefersNoScreen =

View File

@@ -2,6 +2,7 @@ import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as Seasons from "~/features/mmr/core/Seasons";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
@@ -30,6 +31,14 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const isStaff = user?.roles.includes("STAFF") ?? false;
const isParticipant = Boolean(user && matchUsers.includes(user.id));
if (user && isParticipant) {
await resolveNotifications({
userIds: [user.id],
type: "SQ_NEW_MATCH",
meta: { matchId },
});
}
const reportedWeapons = await ReportedWeaponRepository.findByMatchId(matchId);
const match = SendouQ.mapMatch(matchUnmapped, user);

View File

@@ -873,10 +873,29 @@ export async function findReadyCheckByGroupId(groupId: number) {
export function findAllReadyChecksStartedBefore(date: Date) {
return db
.selectFrom("GroupReadyCheck")
.select([
.select((eb) => [
"GroupReadyCheck.id",
"GroupReadyCheck.alphaGroupId",
"GroupReadyCheck.bravoGroupId",
jsonArrayFrom(
eb
.selectFrom("GroupMember")
.select("GroupMember.userId")
.where((innerEb) =>
innerEb.or([
innerEb(
"GroupMember.groupId",
"=",
innerEb.ref("GroupReadyCheck.alphaGroupId"),
),
innerEb(
"GroupMember.groupId",
"=",
innerEb.ref("GroupReadyCheck.bravoGroupId"),
),
]),
),
).as("members"),
])
.where("GroupReadyCheck.createdAt", "<", dateToDatabaseTimestamp(date))
.execute();

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyNotificationsChanged: vi.fn(),
removeRoom: vi.fn(),
setMetadata: vi.fn(),
}));

View File

@@ -1,6 +1,7 @@
import { addMinutes } from "date-fns";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import {
createMatchMemento,
@@ -125,6 +126,8 @@ export async function confirm({
// the ready check ended (e.g. ran out of time) while this request was in flight
if (!confirmation) return null;
await resolveNotifications({ userIds: [userId], type: "SQ_READY_CHECK" });
if (!confirmation.everyoneIsReady) {
revalidateGroups(readyCheck);
@@ -143,12 +146,19 @@ export async function expire(readyCheck: {
id: number;
alphaGroupId: number;
bravoGroupId: number;
members: Array<{ userId: number }>;
}) {
await SQGroupRepository.deleteReadyCheck({
id: readyCheck.id,
markMissedMembers: true,
});
// the ready check no longer exists so there is nothing to respond to
await resolveNotifications({
userIds: readyCheck.members.map((member) => member.userId),
type: "SQ_READY_CHECK",
});
await refreshSendouQInstance();
revalidateGroups(readyCheck);
@@ -240,6 +250,12 @@ async function createMatch({
},
});
// the match superseded the ready check everyone was notified about
await resolveNotifications({
userIds: readyCheck.members.map((member) => member.userId),
type: "SQ_READY_CHECK",
});
return createdMatch.id;
}

View File

@@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
@@ -28,6 +29,13 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
});
}
if (ownGroup) {
await resolveNotifications({
userIds: [user.id],
type: "SQ_ADDED_TO_GROUP",
});
}
const groupsToShow =
ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED"
? []

View File

@@ -1,4 +1,5 @@
import { requireUser } from "~/features/auth/core/user.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { SendouQ } from "../core/SendouQ.server";
import { sqRedirectIfNeeded } from "../q-utils.server";
@@ -12,6 +13,11 @@ export const loader = async () => {
currentLocation: "preparing",
});
await resolveNotifications({
userIds: [user.id],
type: "SQ_ADDED_TO_GROUP",
});
return {
group: ownGroup!,
};

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyNotificationsChanged: vi.fn(),
removeRoom: vi.fn(),
setMetadata: vi.fn(),
}));

View File

@@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyNotificationsChanged: vi.fn(),
removeRoom: vi.fn(),
setMetadata: vi.fn(),
}));

View File

@@ -3,6 +3,7 @@ import * as R from "remeda";
import { db } from "~/db/sql";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
@@ -56,6 +57,14 @@ export const action: ActionFunction = async ({ request, params }) => {
bracketIdx: bracket.sources ? data.bracketIdx : undefined,
});
if (!bracket.sources) {
await resolveNotifications({
userIds: team.memberUserIds,
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId },
});
}
break;
}
case "CHECK_OUT": {

View File

@@ -1,4 +1,5 @@
import type { LoaderFunctionArgs } from "react-router";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import type { SerializeFrom } from "~/utils/remix";
import type { Tournament } from "../core/Tournament";
import {
@@ -24,6 +25,14 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
);
const bracket = tournament.bracketByIdx(bracketIdx);
if (user) {
await resolveNotifications({
userIds: [user.id],
type: "TO_BRACKET_STARTED",
meta: { tournamentId: tournament.ctx.id, bracketIdx },
});
}
return {
bracketIdx,
bracket: bracket ? serializeBracket(bracket) : null,

View File

@@ -1,6 +1,7 @@
import type { ActionFunctionArgs } from "react-router";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server";
import {
clearTournamentDataCache,
@@ -223,6 +224,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
},
});
await resolveNotifications({
userIds: ownGroup.members.map((m) => m.id),
type: "TO_LIKE_RECEIVED",
meta: { tournamentId },
});
break;
}
case "GIVE_MANAGER": {

View File

@@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import type { getUser } from "~/features/auth/core/user.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import {
tournamentFromDBCached,
tournamentFromParams,
@@ -38,6 +39,19 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
throw new Response(null, { status: 404 });
}
if (user) {
await resolveNotifications({
userIds: [user.id],
type: "TO_LIKE_RECEIVED",
meta: { tournamentId },
});
await resolveNotifications({
userIds: [user.id],
type: "TO_LIKE_ACCEPTED",
meta: { tournamentId },
});
}
if (tournament.registrationOpen) {
return lookingMode({ tournamentId, user });
}

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyNotificationsChanged: vi.fn(),
removeRoom: vi.fn(),
setMetadata: vi.fn(),
}));

View File

@@ -3,6 +3,7 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
@@ -260,6 +261,12 @@ export const action: ActionFunction = async ({ request, params }) => {
logger.info(
`Checking in (success): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournamentId}`,
);
await resolveNotifications({
userIds: teamMemberOf.memberUserIds,
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId },
});
break;
}
case "ADD_PLAYER": {

View File

@@ -5,6 +5,7 @@ import {
requireUser,
} from "~/features/auth/core/user.server";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { clearTrophiesCache } from "~/features/trophies/loaders/trophies.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
@@ -121,6 +122,8 @@ export const action: ActionFunction = async ({ request }) => {
errorToastIfFalsy(isOwner || canReview, "Not allowed");
await TrophyRepository.deletePending(data.pendingTrophyId);
await resolveSubmittedNotification(pending.name);
return null;
}
case "DECLINE": {
@@ -153,6 +156,8 @@ export const action: ActionFunction = async ({ request }) => {
});
}
await resolveSubmittedNotification(pending.name);
return null;
}
case "APPROVE": {
@@ -190,6 +195,15 @@ export const action: ActionFunction = async ({ request }) => {
},
});
}
await resolveSubmittedNotification(pending.name);
} else {
// still needs approvals from the other reviewers
await resolveNotifications({
userIds: [user.id],
type: "TROPHY_SUBMITTED",
meta: { trophyName: pending.name },
});
}
return null;
@@ -200,6 +214,14 @@ export const action: ActionFunction = async ({ request }) => {
}
};
function resolveSubmittedNotification(trophyName: string) {
return resolveNotifications({
userIds: [ADMIN_ID, ...QA_IDS],
type: "TROPHY_SUBMITTED",
meta: { trophyName },
});
}
async function notifyReviewersOfSubmission({
trophyName,
submitter,

View File

@@ -45,6 +45,7 @@ import { ChatProvider } from "./features/chat/ChatProvider";
import { isMatchResultsScopedRevalidation } from "./features/chat/revalidation-scope";
import { getSidenavSession } from "./features/layout/core/sidenav-session.server";
import { LayoutDataProvider } from "./features/layout/LayoutDataProvider";
import { NotificationsProvider } from "./features/notifications/NotificationsProvider";
import { sessionIdMiddleware } from "./features/session-id/session-id-middleware.server";
import {
isTheme,
@@ -241,9 +242,11 @@ function Document({
<UnsavedChangesGuard />
<MyFuse data={data} />
<ChatProvider user={data?.user}>
<LayoutDataProvider data={data}>
<Layout data={data}>{children}</Layout>
</LayoutDataProvider>
<NotificationsProvider user={data?.user}>
<LayoutDataProvider data={data}>
<Layout data={data}>{children}</Layout>
</LayoutDataProvider>
</NotificationsProvider>
</ChatProvider>
</I18nProvider>
</RouterProvider>

View File

@@ -320,6 +320,10 @@ export default [
route("/admin/streams", "features/admin/routes/admin.streams.tsx"),
route("/api/chat-users", "features/chat/routes/api.chat-users.ts"),
route("/api/layout", "features/layout/routes/api.layout.ts"),
route(
"/api/notifications",
"features/notifications/routes/api.notifications.ts",
),
route("/api", "features/api/routes/api.tsx"),
...prefix("/a", [

View File

@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyNotificationsChanged: vi.fn(),
removeRoom: vi.fn(),
setMetadata: vi.fn(),
}));

View File

@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
notifyNotificationsChanged: vi.fn(),
removeRoom: vi.fn(),
setMetadata: vi.fn(),
}));

View File

@@ -214,6 +214,7 @@ export const PATRONS_LIST_ROUTE = "/patrons-list";
export const LAYOUT_DATA_ROUTE = "/api/layout";
export const NOTIFICATIONS_URL = "/notifications";
export const NOTIFICATIONS_MARK_AS_SEEN_ROUTE = "/notifications/seen";
export const NOTIFICATIONS_DATA_ROUTE = "/api/notifications";
export const userCardFriendshipPage = (userId: number) =>
`/user-card/${userId}/friendship`;

View File

@@ -1,6 +1,7 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, test } from "./helpers/playwright";
import { FriendsPage } from "./pages/friends/friends-page";
import { NotificationPopover } from "./pages/layout/notification-popover";
test.describe("Friends", () => {
test("send friend request, accept it, then delete friend", async ({
@@ -17,9 +18,16 @@ test.describe("Friends", () => {
await impersonate(page, NZAP_TEST_ID);
await friends.goto();
const notifications = new NotificationPopover(page);
await expect(notifications.locators.bellDot).toBeVisible();
await expect(friends.locators.acceptButton).toBeVisible();
await friends.acceptRequest();
// accepting resolved the friend request notification without the bell
// having been opened
await expect(notifications.locators.bellDot).toBeHidden();
await friends.friend("Sendou").deleteFriend();
await expect(friends.locators.noFriendsText).toBeVisible();

38
e2e/notifications.spec.ts Normal file
View File

@@ -0,0 +1,38 @@
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { expect, impersonate, navigate, test } from "./helpers/playwright";
import { NotificationPopover } from "./pages/layout/notification-popover";
const UNSEEN_COUNT = 2;
test.describe("Notifications", () => {
test("opening the popover clears the bell dot but keeps the unseen dots listed", async ({
page,
factories,
}) => {
for (let seasonNth = 1; seasonNth <= UNSEEN_COUNT; seasonNth++) {
await factories.NotificationFactory.create({
notification: { type: "SEASON_STARTED", meta: { seasonNth } },
users: [{ userId: ADMIN_ID, seen: 0 }],
});
}
await impersonate(page);
await navigate({ page, url: "/" });
const notifications = new NotificationPopover(page);
await expect(notifications.locators.bellDot).toBeVisible();
await notifications.open();
await expect(notifications.locators.bellDot).toBeHidden();
await expect(notifications.locators.unseenDots).toHaveCount(UNSEEN_COUNT);
await notifications.close();
await expect(notifications.locators.items).toHaveCount(0);
await notifications.open();
await expect(notifications.locators.items).toHaveCount(UNSEEN_COUNT);
await expect(notifications.locators.unseenDots).toHaveCount(0);
});
});

View File

@@ -60,7 +60,9 @@ class FriendMenu {
constructor(page: Page, name: string) {
this.page = page;
this.trigger = page.getByRole("button", { name });
// scoped to the page content because the sidebar's friends section shows
// a button with the same name once the friendship data refreshes
this.trigger = page.getByRole("main").getByRole("button", { name });
}
async deleteFriend() {

View File

@@ -12,6 +12,10 @@ export class NotificationPopover {
openButton: this.page.getByTestId("notifications-button"),
items: this.page.getByTestId("notification-item"),
seeAllLink: this.page.getByTestId("notifications-see-all-button"),
/** Shown on the bell while unseen notifications exist. */
bellDot: this.page.getByTestId("notifications-bell-dot"),
/** Per notification, marking it as one the user has not read yet. */
unseenDots: this.page.getByTestId("notification-unseen-dot"),
};
}
@@ -19,6 +23,10 @@ export class NotificationPopover {
await this.locators.openButton.click();
}
async close() {
await this.page.keyboard.press("Escape");
}
notification(text: string) {
return this.locators.items.filter({ hasText: text });
}

View File

@@ -14,6 +14,7 @@ import {
test,
} from "./helpers/playwright";
import { AnythingAdder } from "./pages/layout/anything-adder";
import { NotificationPopover } from "./pages/layout/notification-popover";
import { NewScrimPostPage } from "./pages/scrims/new-scrim-post-page";
import { ScrimPage } from "./pages/scrims/scrim-page";
import { ScrimsPage } from "./pages/scrims/scrims-page";
@@ -128,14 +129,35 @@ test.describe("Scrims", () => {
});
test("accepts a request", async ({ page, factories }) => {
await createPostWithRequest(factories, { ownerUserId: ADMIN_ID });
const post = await createPostWithRequest(factories, {
ownerUserId: ADMIN_ID,
});
await factories.NotificationFactory.create({
notification: {
type: "SCRIM_NEW_REQUEST",
meta: {
fromUserId: NZAP_TEST_ID,
fromUsername: "N-ZAP",
scrimPostId: post.id,
},
},
users: [{ userId: ADMIN_ID }],
});
await impersonate(page, ADMIN_ID);
const scrims = new ScrimsPage(page);
await scrims.goto();
const notifications = new NotificationPopover(page);
await expect(notifications.locators.bellDot).toBeVisible();
await scrims.acceptFirstRequest();
// accepting settled the post, resolving the request notification without
// the bell having been opened
await expect(notifications.locators.bellDot).toBeHidden();
await scrims.openTab("booked");
await expect(scrims.locators.contactLinks).toHaveCount(1);

View File

@@ -13,6 +13,7 @@ import {
runRoutine,
test,
} from "./helpers/playwright";
import { NotificationPopover } from "./pages/layout/notification-popover";
import { SendouQLookingPage } from "./pages/sendouq/sendouq-looking-page";
import { SendouQPage } from "./pages/sendouq/sendouq-page";
import { SendouQReadyPage } from "./pages/sendouq/sendouq-ready-page";
@@ -263,22 +264,36 @@ test.describe("SendouQ", () => {
await expect(ready.locators.membersReady).toHaveCount(1);
await expect(ready.locators.confirmedText).toBeVisible();
const notifications = new NotificationPopover(page);
const restOfTheQueue = [...accepters.slice(1), ...challengers];
for (const member of restOfTheQueue.slice(0, -1)) {
await impersonate(page, member.id);
await ready.goto();
// the ready check notification stays unseen until they respond to it
await expect(notifications.locators.bellDot).toBeVisible();
await ready.confirmReady();
await expect(page).toHaveURL(SENDOUQ_READY_PAGE);
await expect(ready.locators.confirmedText).toBeVisible();
await expect(notifications.locators.bellDot).toBeHidden();
}
// the last one to confirm gets everyone into the match
await impersonate(page, restOfTheQueue.at(-1)!.id);
await ready.goto();
await expect(notifications.locators.bellDot).toBeVisible();
await ready.confirmReady();
await expect(page).toHaveURL(/\/q\/match\/\d+/);
// confirming resolved the ready check notification and the new match
// notification arrives already seen for the one who created the match
await expect(notifications.locators.bellDot).toBeHidden();
});
test("Ready check expiring sends the groups back to looking and lets them kick who missed it", async ({

View File

@@ -8,6 +8,7 @@ import {
navigate,
test,
} from "./helpers/playwright";
import { NotificationPopover } from "./pages/layout/notification-popover";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
import { TournamentPage } from "./pages/tournament/tournament-page";
import { TournamentRegisterPage } from "./pages/tournament/tournament-register-page";
@@ -75,6 +76,13 @@ test.describe("Tournament", () => {
team: pickUpTeam(TEAM_NAME),
memberUserIds: roster.map((user) => user.id),
});
await factories.NotificationFactory.create({
notification: {
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId: tournament.id, tournamentName: "In The Zone" },
},
users: roster.map((user) => ({ userId: user.id })),
});
const opponents = await factories.UserFactory.createMany(2);
for (const [i, opponent] of opponents.entries()) {
@@ -97,8 +105,16 @@ test.describe("Tournament", () => {
const register = new TournamentRegisterPage(page);
await register.goto(tournament.id);
const notifications = new NotificationPopover(page);
await expect(notifications.locators.bellDot).toBeVisible();
await register.checkIn();
// checking in resolved the check-in notification without the bell
// having been opened
await expect(notifications.locators.bellDot).toBeHidden();
const bracketsAfterCheckIn = await register.openBrackets();
await expect(bracketsAfterCheckIn.locators.bracketsViewer).toBeVisible();

View File

@@ -7,6 +7,7 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
import { TROPHIES_PAGE } from "~/utils/urls";
import type { Factories } from "./helpers/factories";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { NotificationPopover } from "./pages/layout/notification-popover";
import { NewTrophyPage } from "./pages/trophies/new-trophy-page";
import { TrophiesPage } from "./pages/trophies/trophies-page";
import { UserPage } from "./pages/user/user-page";
@@ -165,6 +166,13 @@ test.describe("Trophies", () => {
organizationId: organization.id,
submitterUserId: NZAP_TEST_ID,
});
await factories.NotificationFactory.create({
notification: {
type: "TROPHY_SUBMITTED",
meta: { trophyName: name, submitterUsername: "N-ZAP" },
},
users: [{ userId: ADMIN_ID }],
});
}
await impersonate(page);
@@ -174,6 +182,9 @@ test.describe("Trophies", () => {
const pending = await newTrophy.openPending();
const notifications = new NotificationPopover(page);
await expect(notifications.locators.bellDot).toBeVisible();
await pending.approve(approvedName);
await expect(
pending
@@ -181,9 +192,16 @@ test.describe("Trophies", () => {
.getByText(`1/${TROPHY_APPROVALS_REQUIRED} approvals`),
).toBeVisible();
// approving resolved that submission's notification, but the other
// submission still waits for a review
await expect(notifications.locators.bellDot).toBeVisible();
await pending.decline(declinedName, "Does not meet the requirements");
await isNotVisible(pending.row(declinedName));
// with the last pending submission reviewed nothing needs attention anymore
await expect(notifications.locators.bellDot).toBeHidden();
const reviewed = await newTrophy.openReviewed();
await expect(reviewed.row(declinedName)).toBeVisible();
await expect(