mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-19 17:53:06 -05:00
Smarter notification mark as read (#3330)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
216
app/features/notifications/NotificationRepository.server.test.ts
Normal file
216
app/features/notifications/NotificationRepository.server.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
227
app/features/notifications/NotificationsProvider.tsx
Normal file
227
app/features/notifications/NotificationsProvider.tsx
Normal 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]);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
92
app/features/notifications/core/resolve.server.ts
Normal file
92
app/features/notifications/core/resolve.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
105
app/features/notifications/notifications-hooks.browser.test.tsx
Normal file
105
app/features/notifications/notifications-hooks.browser.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
20
app/features/notifications/routes/api.notifications.ts
Normal file
20
app/features/notifications/routes/api.notifications.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
? []
|
||||
|
||||
@@ -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!,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
38
e2e/notifications.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user