Notifications (#2117)

* Initial

* Progress

* Fix

* Progress

* Notifications list page

* BADGE_MANAGER_ADDED

* Mark as seen initial

* Split tables

* Progress

* Fix styles

* Push notifs initial

* Progress

* Rename

* Routines

* Progress

* Add e2e tests

* Done?

* Try updating actions

* Consistency

* Dep fix

* A couple fixes
This commit is contained in:
Kalle 2025-03-01 13:59:34 +02:00 committed by GitHub
parent f711fd718b
commit 4ff0586ff8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 3334 additions and 2207 deletions

View File

@ -32,3 +32,8 @@ VITE_SKALOP_WS_URL=ws://localhost:5900
SQL_LOG=trunc
VITE_SHOW_LUTI_NAV_ITEM=false
// generate here https://vapidkeys.com/
VITE_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_EMAIL=

View File

@ -10,10 +10,10 @@ jobs:
run-checks-and-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"

View File

@ -71,8 +71,8 @@ type LinkButtonProps = Pick<
> &
Pick<LinkProps, "to" | "prefetch" | "preventScrollReset"> & {
"data-cy"?: string;
} & {
isExternal?: boolean;
onClick?: () => void;
};
export function LinkButton({
@ -86,6 +86,7 @@ export function LinkButton({
testId,
icon,
preventScrollReset,
onClick,
}: LinkButtonProps) {
if (isExternal) {
return (
@ -100,6 +101,7 @@ export function LinkButton({
data-testid={testId}
target="_blank"
rel="noreferrer"
onClick={onClick}
>
{icon &&
React.cloneElement(icon, {

View File

@ -11,18 +11,23 @@ export function SendouPopover({
trigger,
popoverClassName,
placement,
onOpenChange,
isOpen,
}: {
children: React.ReactNode;
trigger: React.ReactNode;
popoverClassName?: string;
placement?: PopoverProps["placement"];
onOpenChange?: PopoverProps["onOpenChange"];
isOpen?: boolean;
}) {
return (
<DialogTrigger>
<DialogTrigger isOpen={isOpen}>
{trigger}
<Popover
className={clsx("sendou-popover-content", popoverClassName)}
placement={placement}
onOpenChange={onOpenChange}
>
<Dialog>{children}</Dialog>
</Popover>

View File

@ -0,0 +1,17 @@
export function BellIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>Bell Icon</title>
<path
fillRule="evenodd"
d="M5.25 9a6.75 6.75 0 0 1 13.5 0v.75c0 2.123.8 4.057 2.118 5.52a.75.75 0 0 1-.297 1.206c-1.544.57-3.16.99-4.831 1.243a3.75 3.75 0 1 1-7.48 0 24.585 24.585 0 0 1-4.831-1.244.75.75 0 0 1-.298-1.205A8.217 8.217 0 0 0 5.25 9.75V9Zm4.502 8.9a2.25 2.25 0 1 0 4.496 0 25.057 25.057 0 0 1-4.496 0Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@ -0,0 +1,61 @@
.container {
position: relative;
}
.popoverContainer {
min-width: 300px;
padding: 0;
}
.popoverContainer svg {
width: 16px;
}
.noNotificationsContainer {
min-height: 200px;
}
.topContainer {
display: flex;
justify-content: space-between;
}
.header {
display: flex;
font-size: var(--fonts-sm);
padding-inline: var(--s-1);
padding-bottom: var(--s-1);
gap: var(--s-1);
}
.refreshButton {
margin-block-end: var(--s-1);
margin-inline-end: var(--s-1);
}
.refreshButton svg {
stroke-width: 10px;
}
.noNotifications {
display: grid;
place-items: center;
font-weight: var(--semi-bold);
color: var(--text-lighter);
margin-block-start: 65px;
}
.divider {
border-color: var(--border);
}
.unseenDot {
background-color: var(--theme);
border-radius: 100%;
width: 8px;
height: 8px;
position: absolute;
top: 1px;
left: 1px;
outline: 2px solid var(--theme-transparent);
}

View File

@ -0,0 +1,139 @@
import { useLocation } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core/user";
import {
NotificationItem,
NotificationItemDivider,
NotificationsList,
} from "~/features/notifications/components/NotificationList";
import { NOTIFICATIONS } from "~/features/notifications/notifications-contants";
import { useNotifications } from "~/hooks/swr";
import { NOTIFICATIONS_URL } from "~/utils/urls";
import { useMarkNotificationsAsSeen } from "../../features/notifications/notifications-hooks";
import type { LoaderNotification } from "../../features/notifications/routes/notifications.peek";
import { LinkButton } from "../Button";
import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
import { BellIcon } from "../icons/Bell";
import { RefreshIcon } from "../icons/Refresh";
import styles from "./NotificationPopover.module.css";
export function NotificationPopover() {
const location = useLocation();
const user = useUser();
const { notifications, isLoading, refresh } = useNotifications();
const unseenIds = React.useMemo(
() =>
notifications
?.filter((notification) => !notification.seen)
.map((notification) => notification.id) ?? [],
[notifications],
);
if (!user) {
return null;
}
return (
<div className={styles.container} key={location.pathname}>
{unseenIds.length > 0 ? <div className={styles.unseenDot} /> : null}
<SendouPopover
trigger={
<SendouButton
icon={<BellIcon />}
className="layout__header__button"
data-testid="notifications-button"
/>
}
popoverClassName={clsx(styles.popoverContainer, {
[styles.noNotificationsContainer]:
!notifications || notifications.length === 0,
})}
>
<NotificationContent
notifications={notifications ?? []}
unseenIds={unseenIds}
isLoading={isLoading}
refresh={refresh}
/>
</SendouPopover>
</div>
);
}
function NotificationContent({
notifications,
unseenIds,
refresh,
isLoading,
}: {
notifications: LoaderNotification[];
unseenIds: number[];
refresh: () => void;
isLoading: boolean;
}) {
const { t } = useTranslation(["common"]);
useMarkNotificationsAsSeen(unseenIds);
return (
<>
<div className={styles.topContainer}>
<h2 className={styles.header}>
<BellIcon /> {t("common:notifications.title")}
</h2>
<SendouButton
icon={<RefreshIcon />}
variant="minimal"
className={styles.refreshButton}
onPress={refresh}
isDisabled={isLoading}
/>
</div>
<hr className={styles.divider} />
{notifications.length === 0 ? (
<div className={styles.noNotifications}>
{isLoading
? t("common:notifications.loading")
: t("common:notifications.empty")}
</div>
) : (
<NotificationsList>
{notifications.map((notification, i) => (
<React.Fragment key={notification.id}>
<NotificationItem
key={notification.id}
notification={notification}
/>
{i !== notifications.length - 1 && <NotificationItemDivider />}
</React.Fragment>
))}
</NotificationsList>
)}
{notifications.length === NOTIFICATIONS.PEEK_COUNT ? (
<NotificationsFooter />
) : null}
</>
);
}
function NotificationsFooter() {
const { t } = useTranslation(["common"]);
return (
<div>
<hr className={styles.divider} />
<LinkButton
variant="minimal"
size="tiny"
to={NOTIFICATIONS_URL}
className="mt-1-5"
>
{t("common:notifications.seeAll")}
</LinkButton>
</div>
);
}

View File

@ -5,6 +5,7 @@ import { LinkButton } from "../Button";
import { HamburgerIcon } from "../icons/Hamburger";
import { HeartIcon } from "../icons/Heart";
import { AnythingAdder } from "./AnythingAdder";
import { NotificationPopover } from "./NotificationPopover";
import { UserItem } from "./UserItem";
export function _TopRightButtons({
@ -30,6 +31,7 @@ export function _TopRightButtons({
{t("common:pages.support")}
</LinkButton>
) : null}
<NotificationPopover />
<AnythingAdder />
<button
aria-label="Open navigation"

View File

@ -1,43 +1,26 @@
import { faker } from "@faker-js/faker";
import { sub } from "date-fns";
import capitalize from "just-capitalize";
import shuffle from "just-shuffle";
import { nanoid } from "nanoid";
import { ADMIN_DISCORD_ID, ADMIN_ID, INVITE_CODE_LENGTH } from "~/constants";
import { db, sql } from "~/db/sql";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import {
lastCompletedVoting,
nextNonCompletedVoting,
rangeToMonthYear,
} from "~/features/plus-voting/core";
import { createVod } from "~/features/vods/queries/createVod.server";
import type {
AbilityType,
MainWeaponId,
StageId,
} from "~/modules/in-game-lists";
import {
abilities,
clothesGearIds,
headGearIds,
mainWeaponIds,
modesShort,
shoesGearIds,
stageIds,
} from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { mySlugify } from "~/utils/urls";
import type { SeedVariation } from "~/features/api-private/routes/seed";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { persistedTags } from "~/features/calendar/calendar-constants";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { TIMEZONES } from "~/features/lfg/lfg-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
import type { Notification } from "~/features/notifications/notifications-types";
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
import {
lastCompletedVoting,
nextNonCompletedVoting,
rangeToMonthYear,
} from "~/features/plus-voting/core";
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
@ -61,13 +44,32 @@ import { setGroupAsInactive } from "~/features/sendouq/queries/setGroupAsInactiv
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { createVod } from "~/features/vods/queries/createVod.server";
import {
secondsToHoursMinutesSecondString,
youtubeIdToYoutubeUrl,
} from "~/features/vods/vods-utils";
import type {
AbilityType,
MainWeaponId,
StageId,
} from "~/modules/in-game-lists";
import {
abilities,
clothesGearIds,
headGearIds,
mainWeaponIds,
modesShort,
shoesGearIds,
stageIds,
} from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
import { nullFilledArray, pickRandomItem } from "~/utils/arrays";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { mySlugify } from "~/utils/urls";
import type { Tables, UserMapModePreferences } from "../tables";
import type { Art, UserSubmittedImage } from "../types";
import {
@ -170,6 +172,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
groups,
friendCodes,
lfgPosts,
notifications,
];
export async function seed(variation?: SeedVariation | null) {
@ -220,6 +223,8 @@ function wipeDB() {
"XRankPlacement",
"SplatoonPlayer",
"UserFriendCode",
"NotificationUser",
"Notification",
"User",
"PlusSuggestion",
"PlusVote",
@ -557,7 +562,7 @@ async function lastMonthSuggestions() {
}
async function thisMonthsSuggestions() {
const usersInPlus = (await UserRepository.findAllPlusMembers()).filter(
const usersInPlus = (await UserRepository.findAllPlusServerMembers()).filter(
(u) => u.id !== ADMIN_ID,
);
const range = nextNonCompletedVoting(new Date());
@ -640,7 +645,12 @@ function badgesToUsers() {
});
i++
) {
const userToGetABadge = userIds.shift()!;
let userToGetABadge = userIds.shift()!;
if (userToGetABadge === NZAP_TEST_ID && id === 1) {
// e2e test assumes N-ZAP does not have badge id = 1
userToGetABadge = userIds.shift()!;
}
sql
.prepare(
`insert into "TournamentBadgeOwner" ("badgeId", "userId") values ($id, $userId)`,
@ -2272,3 +2282,110 @@ async function lfgPosts() {
teamId: 1,
});
}
async function notifications() {
const values: Notification[] = [
{
type: "PLUS_SUGGESTION_ADDED",
meta: { tier: 1 },
},
{
type: "SEASON_STARTED",
meta: { seasonNth: 1 },
},
{
type: "TO_ADDED_TO_TEAM",
meta: {
adderUsername: "N-ZAP",
teamName: "Chimera",
tournamentId: 1,
tournamentName: "PICNIC #2",
tournamentTeamId: 1,
},
},
{
type: "TO_BRACKET_STARTED",
meta: {
tournamentId: 1,
tournamentName: "PICNIC #2",
bracketIdx: 0,
bracketName: "Groups Stage",
},
},
{
type: "BADGE_ADDED",
meta: { badgeName: "In The Zone 20-29", badgeId: 39 },
},
{
type: "TAGGED_TO_ART",
meta: {
adderUsername: "N-ZAP",
adderDiscordId: NZAP_TEST_DISCORD_ID,
artId: 1, // does not exist
},
},
{
type: "SQ_ADDED_TO_GROUP",
meta: { adderUsername: "N-ZAP" },
},
{
type: "SQ_NEW_MATCH",
meta: { matchId: 100 },
},
{
type: "PLUS_VOTING_STARTED",
meta: { seasonNth: 1 },
},
{
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId: 1, tournamentName: "PICNIC #2" },
pictureUrl:
"http://localhost:5173/static-assets/img/tournament-logos/pn.png",
},
];
for (const [i, value] of values.entries()) {
await NotificationRepository.insert(value, [
{
userId: ADMIN_ID,
seen: i <= 7 ? 1 : 0,
},
]);
await NotificationRepository.insert(value, [
{
userId: NZAP_TEST_ID,
seen: i <= 7 ? 1 : 0,
},
]);
}
const createdAts = [
sub(new Date(), { days: 10 }),
sub(new Date(), { days: 8 }),
sub(new Date(), { days: 5, hours: 2 }),
sub(new Date(), { days: 4, minutes: 30 }),
sub(new Date(), { days: 3, hours: 2 }),
sub(new Date(), { days: 3, hours: 1, minutes: 10 }),
sub(new Date(), { days: 2, hours: 5 }),
sub(new Date(), { minutes: 10 }),
sub(new Date(), { minutes: 5 }),
];
invariant(
values.length - 1 === createdAts.length,
"values and createdAts length mismatch",
);
for (let i = 0; i < values.length - 1; i++) {
sql
.prepare(/* sql */ `
update "Notification"
set "createdAt" = @createdAt
where "id" = @id
`)
.run({
createdAt: dateToDatabaseTimestamp(createdAts[i]),
id: i + 1,
});
}
}

View File

@ -7,6 +7,7 @@ import type {
Updateable,
} from "kysely";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { ParticipantResult } from "~/modules/brackets-model";
@ -877,6 +878,38 @@ export interface XRankPlacement {
year: number;
}
export interface Notification {
id: GeneratedAlways<number>;
type: NotificationValue["type"];
meta: ColumnType<
Record<string, number | string> | null,
string | null,
string | null
>;
pictureUrl: string | null;
createdAt: GeneratedAlways<number>;
}
export interface NotificationUser {
notificationId: number;
userId: number;
seen: Generated<number>;
}
export interface NotificationSubscription {
endpoint: string;
keys: {
auth: string;
p256dh: string;
};
}
export interface NotificationUserSubscription {
id: GeneratedAlways<number>;
userId: number;
subscription: ColumnType<NotificationSubscription, string, string>;
}
export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
export type TablesUpdatable = { [P in keyof DB]: Updateable<DB[P]> };
@ -955,4 +988,7 @@ export interface DB {
VideoMatch: VideoMatch;
VideoMatchPlayer: VideoMatchPlayer;
XRankPlacement: XRankPlacement;
Notification: Notification;
NotificationUser: NotificationUser;
NotificationUserSubscription: NotificationUserSubscription;
}

View File

@ -9,12 +9,10 @@ import { isbot } from "isbot";
import cron from "node-cron";
import { renderToPipeableStream } from "react-dom/server";
import { I18nextProvider, initReactI18next } from "react-i18next";
import * as QRepository from "~/features/sendouq/QRepository.server";
import { config } from "~/modules/i18n/config"; // your i18n configuration file
import i18next from "~/modules/i18n/i18next.server";
import { resources } from "./modules/i18n/resources.server";
import { updatePatreonData } from "./modules/patreon";
import { logger } from "./utils/logger";
import { daily, everyHourAt00, everyHourAt30 } from "./routines/list.server";
const ABORT_DELAY = 5000;
@ -86,18 +84,23 @@ declare global {
if (!global.appStartSignal && process.env.NODE_ENV === "production") {
global.appStartSignal = true;
// every 2 hours
cron.schedule("0 */2 * * *", () =>
updatePatreonData().catch((err) => console.error(err)),
);
// every hour
cron.schedule("0 */1 * * *", async () => {
const { numDeletedRows } = await QRepository.deleteOldTrust();
logger.info(`Deleted ${numDeletedRows} old trusts`);
for (const routine of everyHourAt00) {
await routine.run();
}
});
const { numUpdatedRows } = await QRepository.setOldGroupsAsInactive();
logger.info(`Set ${numUpdatedRows} as inactive`);
cron.schedule("30 */1 * * *", async () => {
for (const routine of everyHourAt30) {
await routine.run();
}
});
// 4:00 AM UTC
cron.schedule("0 4 * * *", async () => {
for (const routine of daily) {
await routine.run();
}
});
}

View File

@ -66,6 +66,7 @@ const updateArtStm = sql.prepare(/* sql */ `
"isShowcase" = @isShowcase
where
"id" = @artId
returning *
`);
const removeIsShowcaseFromAllStm = sql.prepare(/* sql */ `
@ -128,6 +129,8 @@ export const addNewArt = sql.transaction((args: AddNewArtArgs) => {
addTaggedArtStm.run({ artId: art.id, tagId });
}
return art.id;
});
type EditArtArgs = Pick<Art, "authorId" | "description" | "isShowcase"> & {
@ -143,7 +146,7 @@ export const editArt = sql.transaction((args: EditArtArgs) => {
});
}
updateArtStm.run({
const updatedArt = updateArtStm.get({
description: args.description,
isShowcase: args.isShowcase,
artId: args.artId,
@ -169,4 +172,6 @@ export const editArt = sql.transaction((args: EditArtArgs) => {
addTaggedArtStm.run({ artId: args.artId, tagId });
}
return (updatedArt as { id: number }).id;
});

View File

@ -28,6 +28,7 @@ import { CrossIcon } from "~/components/icons/Cross";
import { useUser } from "~/features/auth/core/user";
import { requireUser } from "~/features/auth/core/user.server";
import { s3UploadHandler } from "~/features/img-upload";
import { notify } from "~/features/notifications/core/notify.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
@ -88,7 +89,7 @@ export const action: ActionFunction = async ({ request }) => {
schema: editArtSchema,
});
editArt({
const editedArtId = editArt({
authorId: user.id,
artId,
description: data.description,
@ -96,6 +97,22 @@ export const action: ActionFunction = async ({ request }) => {
linkedUsers: data.linkedUsers,
tags: data.tags,
});
const newLinkedUsers = data.linkedUsers.filter(
(userId) => !existingArt.linkedUsers.includes(userId),
);
notify({
userIds: newLinkedUsers,
notification: {
type: "TAGGED_TO_ART",
meta: {
adderUsername: user.username,
adderDiscordId: user.discordId,
artId: editedArtId,
},
},
});
} else {
const uploadHandler = composeUploadHandlers(
s3UploadHandler(`art-${nanoid()}-${Date.now()}`),
@ -114,7 +131,7 @@ export const action: ActionFunction = async ({ request }) => {
schema: newArtSchema,
});
addNewArt({
const addedArtId = addNewArt({
authorId: user.id,
description: data.description,
url: fileName,
@ -122,6 +139,18 @@ export const action: ActionFunction = async ({ request }) => {
linkedUsers: data.linkedUsers,
tags: data.tags,
});
notify({
userIds: data.linkedUsers,
notification: {
type: "TAGGED_TO_ART",
meta: {
adderUsername: user.username,
adderDiscordId: user.discordId,
artId: addedArtId,
},
},
});
}
throw redirect(userArtPage(user));

View File

@ -1,12 +1,9 @@
import { OAuth2Strategy } from "remix-auth-oauth2";
import { z } from "zod";
import type { User } from "~/db/types";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
export type LoggedInUser = User["id"];
const partialDiscordUserSchema = z.object({
avatar: z.string().nullish(),
discriminator: z.string(),

View File

@ -1,9 +1,9 @@
import { Authenticator } from "remix-auth";
import { DiscordStrategy, type LoggedInUser } from "./DiscordStrategy.server";
import { DiscordStrategy } from "./DiscordStrategy.server";
export const SESSION_KEY = "user";
export const IMPERSONATED_SESSION_KEY = "impersonated_user";
export const authenticator = new Authenticator<LoggedInUser>();
export const authenticator = new Authenticator<number>();
authenticator.use(DiscordStrategy(), "discord");

View File

@ -32,6 +32,14 @@ export async function all() {
}));
}
export async function findById(badgeId: number) {
return db
.selectFrom("Badge")
.select(["Badge.displayName"])
.where("id", "=", badgeId)
.executeTakeFirst();
}
export async function findByOwnerId({
userId,
favoriteBadgeId,

View File

@ -11,9 +11,14 @@ import { TrashIcon } from "~/components/icons/Trash";
import type { User } from "~/db/types";
import { useUser } from "~/features/auth/core/user";
import { requireUserId } from "~/features/auth/core/user.server";
import { notify } from "~/features/notifications/core/notify.server";
import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions";
import { atOrError } from "~/utils/arrays";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { atOrError, diff } from "~/utils/arrays";
import {
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { badgePage } from "~/utils/urls";
import { actualNumber } from "~/utils/zod";
@ -29,14 +34,34 @@ export const action: ActionFunction = async ({ request, params }) => {
const badgeId = z.preprocess(actualNumber, z.number()).parse(params.id);
const user = await requireUserId(request);
const badge = notFoundIfFalsy(await BadgeRepository.findById(badgeId));
switch (data._action) {
case "MANAGERS": {
validate(canEditBadgeManagers(user));
const oldManagers = await BadgeRepository.findManagersByBadgeId(badgeId);
await BadgeRepository.replaceManagers({
badgeId,
managerIds: data.managerIds,
});
const newManagers = data.managerIds.filter(
(newManagerId) =>
!oldManagers.some((oldManager) => oldManager.id === newManagerId),
);
notify({
userIds: newManagers,
notification: {
type: "BADGE_MANAGER_ADDED",
meta: {
badgeId,
badgeName: badge.displayName,
},
},
});
break;
}
case "OWNERS": {
@ -47,7 +72,24 @@ export const action: ActionFunction = async ({ request, params }) => {
}),
);
const oldOwners = await BadgeRepository.findOwnersByBadgeId(badgeId);
await BadgeRepository.replaceOwners({ badgeId, ownerIds: data.ownerIds });
notify({
userIds: diff(
oldOwners.map((o) => o.id),
data.ownerIds,
),
notification: {
type: "BADGE_ADDED",
meta: {
badgeName: badge.displayName,
badgeId,
},
},
});
break;
}
default: {
@ -113,8 +155,14 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
<div className="stack sm">
<h3 className="badges-edit__small-header">Managers</h3>
<div className="text-center my-4">
<Label className="stack vertical items-center">Add new manager</Label>
<Label
className="stack vertical items-center"
htmlFor="add-new-manager"
>
Add new manager
</Label>
<UserSearch
id="add-new-manager"
className="mx-auto"
inputName="new-manager"
onChange={(user) => {
@ -177,8 +225,11 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
<div className="stack sm">
<h3 className="badges-edit__small-header">Owners</h3>
<div className="text-center my-4">
<Label className="stack items-center">Add new owner</Label>
<Label className="stack items-center" htmlFor="add-new-owner">
Add new owner
</Label>
<UserSearch
id="add-new-owner"
className="mx-auto"
inputName="new-owner"
key={userInputKey}

View File

@ -143,12 +143,12 @@ export type FindAllBetweenTwoTimestampsItem = Unwrapped<
export async function findAllBetweenTwoTimestamps({
startTime,
endTime,
tagsToFilterBy,
tagsToFilterBy = [],
onlyTournaments,
}: {
startTime: Date;
endTime: Date;
tagsToFilterBy: Array<PersistedCalendarEventTag>;
tagsToFilterBy?: Array<PersistedCalendarEventTag>;
onlyTournaments: boolean;
}) {
let query = db

View File

@ -7,8 +7,8 @@ type PartialChatMessage = Pick<
ChatMessage,
"type" | "context" | "room" | "revalidateOnly"
>;
interface NotificationService {
notify: (msg: PartialChatMessage | PartialChatMessage[]) => undefined;
interface ChatSystemMessageService {
send: (msg: PartialChatMessage | PartialChatMessage[]) => undefined;
}
invariant(
@ -17,7 +17,7 @@ invariant(
);
invariant(process.env.SKALOP_TOKEN, "Missing env var: SKALOP_TOKEN");
export const notify: NotificationService["notify"] = (partialMsg) => {
export const send: ChatSystemMessageService["send"] = (partialMsg) => {
const msgArr = Array.isArray(partialMsg) ? partialMsg : [partialMsg];
const fullMessages: ChatMessage[] = msgArr.map((partialMsg) => {

View File

@ -0,0 +1,122 @@
import { sub } from "date-fns";
import { db } from "~/db/sql";
import type { NotificationSubscription, TablesInsertable } from "~/db/tables";
import { dateToDatabaseTimestamp } from "../../utils/dates";
import { NOTIFICATIONS } from "./notifications-contants";
import type { Notification } from "./notifications-types";
export function insert(
notification: Notification,
users: Array<Omit<TablesInsertable["NotificationUser"], "notificationId">>,
) {
return db.transaction().execute(async (trx) => {
const inserted = await trx
.insertInto("Notification")
.values({
type: notification.type,
pictureUrl: notification.pictureUrl,
meta: notification.meta ? JSON.stringify(notification.meta) : null,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("NotificationUser")
.values(
users.map(({ userId }) => ({
userId,
notificationId: inserted.id,
})),
)
.execute();
});
}
export function findByUserId(
userId: number,
{ limit }: { limit?: number } = {},
) {
return db
.selectFrom("NotificationUser")
.innerJoin(
"Notification",
"Notification.id",
"NotificationUser.notificationId",
)
.select([
"Notification.id",
"Notification.createdAt",
"NotificationUser.seen",
"Notification.type",
"Notification.meta",
"Notification.pictureUrl",
])
.where("NotificationUser.userId", "=", userId)
.limit(limit ?? NOTIFICATIONS.MAX_SHOWN)
.orderBy("Notification.id", "desc")
.execute() as Promise<
Array<Notification & { id: number; createdAt: number; seen: number }>
>;
}
export function findAllByType<T extends Notification["type"]>(type: T) {
return db
.selectFrom("Notification")
.select(["type", "meta", "pictureUrl"])
.where("type", "=", type)
.execute() as Promise<Array<Extract<Notification, { type: T }>>>;
}
export function markAsSeen({
notificationIds,
userId,
}: {
notificationIds: number[];
userId: number;
}) {
return db
.updateTable("NotificationUser")
.set("seen", 1)
.where("NotificationUser.notificationId", "in", notificationIds)
.where("NotificationUser.userId", "=", userId)
.execute();
}
export function deleteOld() {
return db
.deleteFrom("Notification")
.where(
"createdAt",
"<",
dateToDatabaseTimestamp(sub(new Date(), { days: 14 })),
)
.executeTakeFirst();
}
export function addSubscription(args: {
userId: number;
subscription: NotificationSubscription;
}) {
return db
.insertInto("NotificationUserSubscription")
.values({
userId: args.userId,
subscription: JSON.stringify(args.subscription),
})
.execute();
}
export function subscriptionsByUserIds(userIds: number[]) {
return db
.selectFrom("NotificationUserSubscription")
.select(["id", "subscription"])
.where("userId", "in", userIds)
.execute();
}
export function deleteSubscriptionById(id: number) {
return db
.deleteFrom("NotificationUserSubscription")
.where("id", "=", id)
.execute();
}

View File

@ -0,0 +1,66 @@
.itemDivider {
margin-inline: var(--s-2-5);
border-width: 0.5px;
border-color: var(--border);
}
.item {
line-height: 1.4;
padding: var(--s-1) var(--s-2-5);
display: grid;
grid-template-areas: "image header" "image timestamp";
grid-template-columns: 30px 1fr;
column-gap: var(--s-2);
padding-block: var(--s-2-5);
color: var(--text);
}
.item:hover .imageContainer {
outline: 3px solid var(--theme-transparent);
}
.item:focus-within .imageContainer {
outline: 3px solid var(--theme-transparent);
}
.unseenDot {
background-color: var(--theme);
border-radius: 100%;
width: 8px;
height: 8px;
position: absolute;
top: -1px;
left: -1px;
outline: 2px solid var(--theme-transparent);
}
.imageContainer {
place-self: center;
grid-area: image;
border-radius: 100%;
width: 30px;
height: 30px;
background-color: var(--bg-lightest);
display: grid;
place-items: center;
position: relative;
}
.itemImage {
width: 30px;
height: 30px;
border-radius: 100%;
}
.itemHeader {
grid-area: header;
font-size: var(--fonts-xsm);
font-weight: var(--semi-bold);
}
.timestamp {
grid-area: timestamp;
color: var(--text-lighter);
font-size: var(--fonts-xxs);
font-weight: var(--body);
}

View File

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

View File

@ -0,0 +1,138 @@
import type { TFunction } from "i18next";
import pLimit from "p-limit";
import { WebPushError } from "web-push";
import type { NotificationSubscription } from "../../../db/tables";
import i18next from "../../../modules/i18n/i18next.server";
import { logger } from "../../../utils/logger";
import * as NotificationRepository from "../NotificationRepository.server";
import type { Notification } from "../notifications-types";
import { notificationLink } from "../notifications-utils";
import webPush from "./webPush.server";
/**
* Create notifications both in the database and send push notifications to users (if enabled).
*/
export async function notify({
userIds,
notification,
defaultSeenUserIds,
}: {
/** Array of user ids to notify */
userIds: Array<number>;
/** Array of user ids that should have the notification marked as seen by default */
defaultSeenUserIds?: Array<number>;
/** Notification to send (same for all users) */
notification: Notification;
}) {
if (userIds.length === 0) {
return;
}
if (isNotificationAlreadySent(notification)) {
return;
}
const dededuplicatedUserIds = Array.from(new Set(userIds));
try {
await NotificationRepository.insert(
notification,
dededuplicatedUserIds.map((userId) => ({
userId,
seen: defaultSeenUserIds?.includes(userId) ? 1 : 0,
})),
);
} catch (e) {
console.error("Failed to notify users", e);
}
const subscriptions = await NotificationRepository.subscriptionsByUserIds(
dededuplicatedUserIds,
);
if (subscriptions.length > 0) {
const t = await i18next.getFixedT("en-US", ["common"]);
const limit = pLimit(50);
await Promise.all(
subscriptions.map(({ id, subscription }) =>
limit(() =>
sendPushNotification({
subscription,
subscriptionId: id,
notification,
t,
}),
),
),
);
}
}
const sentNotifications = new Set<string>();
// deduplicates notifications as a failsafe & anti-abuse mechanism
function isNotificationAlreadySent(notification: Notification) {
// e2e tests should not be affected by this
if (process.env.NODE_ENV !== "production") {
return false;
}
const key = `${notification.type}-${JSON.stringify(notification.meta)}`;
if (sentNotifications.has(key)) {
return true;
}
sentNotifications.add(key);
if (sentNotifications.size > 10_000) {
sentNotifications.clear();
}
return false;
}
async function sendPushNotification({
subscription,
subscriptionId,
notification,
t,
}: {
subscription: NotificationSubscription;
subscriptionId: number;
notification: Notification;
t: TFunction<["common"], undefined>;
}) {
try {
logger.info("Sending...", subscription);
await webPush.sendNotification(
subscription,
JSON.stringify(pushNotificationOptions(notification, t)),
);
} catch (err) {
if (!(err instanceof WebPushError)) {
logger.error("Failed to send push notification (unknown error)", err);
// if we get "Not Found" or "Gone" we should delete the subscription as it is expired or no longer valid
} else if (err.statusCode === 404 || err.statusCode === 410) {
await NotificationRepository.deleteSubscriptionById(subscriptionId);
} else {
logger.error("Failed to send push notification", err);
}
}
}
function pushNotificationOptions(
notification: Notification,
t: TFunction<["common"], undefined>,
): Parameters<ServiceWorkerRegistration["showNotification"]>[1] & {
title: string;
} {
return {
title: t(`common:notifications.title.${notification.type}`),
body: t(
`common:notifications.text.${notification.type}`,
notification.meta,
),
icon: notification.pictureUrl ?? "/static-assets/img/app-icon.png",
data: { url: notificationLink(notification) },
};
}

View File

@ -0,0 +1,18 @@
import webPush from "web-push";
import { logger } from "~/utils/logger";
if (
process.env.VAPID_EMAIL &&
process.env.VITE_VAPID_PUBLIC_KEY &&
process.env.VAPID_PRIVATE_KEY
) {
webPush.setVapidDetails(
process.env.VAPID_EMAIL,
process.env.VITE_VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY,
);
} else {
logger.info("VAPID env vars not set, push notifications will not work");
}
export default webPush;

View File

@ -0,0 +1,11 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import * as NotificationRepository from "../NotificationRepository.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUserId(request);
return {
notifications: await NotificationRepository.findByUserId(user.id),
};
};

View File

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

View File

@ -0,0 +1,20 @@
import { useFetcher } from "@remix-run/react";
import * as React from "react";
import { NOTIFICATIONS_MARK_AS_SEEN_ROUTE } from "~/utils/urls";
export function useMarkNotificationsAsSeen(unseenIds: number[]) {
const fetcher = useFetcher();
React.useEffect(() => {
if (!unseenIds.length || fetcher.state !== "idle") return;
fetcher.submit(
{ notificationIds: unseenIds },
{
method: "post",
encType: "application/json",
action: NOTIFICATIONS_MARK_AS_SEEN_ROUTE,
},
);
}, [fetcher, unseenIds]);
}

View File

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

View File

@ -0,0 +1,63 @@
export type Notification =
| NotificationItem<
"SQ_ADDED_TO_GROUP",
{
adderUsername: string;
}
>
| NotificationItem<
"SQ_NEW_MATCH",
{
matchId: number;
}
>
| NotificationItem<
"TO_ADDED_TO_TEAM",
{
tournamentId: number;
tournamentName: string;
adderUsername: string;
teamName: string;
tournamentTeamId: number;
}
>
| NotificationItem<
"TO_BRACKET_STARTED",
{
tournamentId: number;
bracketIdx: number;
bracketName: string;
tournamentName: string;
}
>
| NotificationItem<
"TO_CHECK_IN_OPENED",
{
tournamentId: number;
tournamentName: string;
}
>
| NotificationItem<"BADGE_ADDED", { badgeName: string; badgeId: number }>
| NotificationItem<
"BADGE_MANAGER_ADDED",
{ badgeName: string; badgeId: number }
>
| NotificationItem<
"PLUS_VOTING_STARTED",
{
seasonNth: number;
}
>
| NotificationItem<"PLUS_SUGGESTION_ADDED", { tier: number }>
| NotificationItem<
"TAGGED_TO_ART",
{ adderUsername: string; adderDiscordId: string; artId: number }
>
| NotificationItem<"SEASON_STARTED", { seasonNth: number }>;
type NotificationItem<
T extends string,
M extends Record<string, number | string> | undefined = undefined,
> = M extends undefined
? { type: T; pictureUrl?: string }
: { type: T; meta: M; pictureUrl?: string };

View File

@ -0,0 +1,74 @@
import { assertUnreachable } from "~/utils/types";
import {
PLUS_VOTING_PAGE,
SENDOUQ_PAGE,
badgePage,
plusSuggestionPage,
sendouQMatchPage,
tournamentBracketsPage,
tournamentRegisterPage,
tournamentTeamPage,
userArtPage,
} from "~/utils/urls";
import type { Notification } from "./notifications-types";
export const notificationNavIcon = (type: Notification["type"]) => {
switch (type) {
case "BADGE_ADDED":
case "BADGE_MANAGER_ADDED":
return "badges";
case "PLUS_SUGGESTION_ADDED":
case "PLUS_VOTING_STARTED":
return "plus";
case "SQ_ADDED_TO_GROUP":
case "SQ_NEW_MATCH":
case "SEASON_STARTED":
return "sendouq";
case "TAGGED_TO_ART":
return "art";
case "TO_ADDED_TO_TEAM":
case "TO_BRACKET_STARTED":
case "TO_CHECK_IN_OPENED":
return "medal";
default:
assertUnreachable(type);
}
};
export const notificationLink = (notification: Notification) => {
switch (notification.type) {
case "BADGE_ADDED":
return badgePage(notification.meta.badgeId);
case "BADGE_MANAGER_ADDED":
return badgePage(notification.meta.badgeId);
case "PLUS_SUGGESTION_ADDED":
return plusSuggestionPage({ tier: notification.meta.tier });
case "PLUS_VOTING_STARTED":
return PLUS_VOTING_PAGE;
case "SEASON_STARTED":
case "SQ_ADDED_TO_GROUP":
return SENDOUQ_PAGE;
case "SQ_NEW_MATCH":
return sendouQMatchPage(notification.meta.matchId);
case "TAGGED_TO_ART":
return userArtPage(
{ discordId: notification.meta.adderDiscordId },
"MADE-BY",
notification.meta.artId,
);
case "TO_ADDED_TO_TEAM":
return tournamentTeamPage({
tournamentId: notification.meta.tournamentId,
tournamentTeamId: notification.meta.tournamentTeamId,
});
case "TO_BRACKET_STARTED":
return tournamentBracketsPage({
tournamentId: notification.meta.tournamentId,
bracketIdx: notification.meta.bracketIdx,
});
case "TO_CHECK_IN_OPENED":
return tournamentRegisterPage(notification.meta.tournamentId);
default:
assertUnreachable(notification);
}
};

View File

@ -0,0 +1,8 @@
.header {
display: flex;
gap: var(--s-2-5);
}
.header svg {
width: 24px;
}

View File

@ -0,0 +1,19 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import type { SerializeFrom } from "~/utils/remix";
import * as NotificationRepository from "../NotificationRepository.server";
import { NOTIFICATIONS } from "../notifications-contants";
export type NotificationsLoaderData = SerializeFrom<typeof loader>;
export type LoaderNotification =
NotificationsLoaderData["notifications"][number];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const notifications = await NotificationRepository.findByUserId(user.id, {
limit: NOTIFICATIONS.PEEK_COUNT,
});
return { notifications };
};

View File

@ -0,0 +1,20 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import { parseRequestPayload } from "~/utils/remix.server";
import * as NotificationRepository from "../NotificationRepository.server";
import { markAsSeenActionSchema } from "../notifications-schemas";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUserId(request);
const data = await parseRequestPayload({
request,
schema: markAsSeenActionSchema,
});
await NotificationRepository.markAsSeen({
userId: user.id,
notificationIds: data.notificationIds,
});
return null;
};

View File

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

View File

@ -0,0 +1,89 @@
import { Link, type MetaFunction, useLoaderData } from "@remix-run/react";
import { Main } from "~/components/Main";
import {
NotificationItem,
NotificationItemDivider,
NotificationsList,
} from "../components/NotificationList";
import { loader } from "../loaders/notifications.server";
export { loader };
import * as React from "react";
import { useTranslation } from "react-i18next";
import { BellIcon } from "~/components/icons/Bell";
import { metaTags } from "../../../utils/remix";
import { SETTINGS_PAGE } from "../../../utils/urls";
import { useMarkNotificationsAsSeen } from "../notifications-hooks";
import styles from "./notifications.module.css";
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Notifications",
location: args.location,
});
};
export default function NotificationsPage() {
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const [unseenIds, setUnseenIds] = React.useState(new Set<number>());
// persist unseen dots for the duration of the page being viewed
React.useEffect(() => {
setUnseenIds((prevUnseenIds) => {
const newUnseenIds = new Set(prevUnseenIds);
for (const id of data.notifications
.filter((notification) => !notification.seen)
.map((notification) => notification.id)) {
newUnseenIds.add(id);
}
// optimize render by not updating state if nothing changed
if (newUnseenIds.size === prevUnseenIds.size) return prevUnseenIds;
return newUnseenIds;
});
}, [data.notifications]);
const unSeenIdsArr = React.useMemo(() => Array.from(unseenIds), [unseenIds]);
useMarkNotificationsAsSeen(unSeenIdsArr);
return (
<Main className="stack md">
<div className="stack horizontal justify-between items-center flex-wrap">
<h2 className={styles.header}>
<BellIcon /> {t("common:notifications.title")}
</h2>
<Link className="text-xs" to={SETTINGS_PAGE}>
{t("common:notifications.managePush")}
</Link>
</div>
{data.notifications.length === 0 ? (
<div className="layout__notifications__no-notifications">
{t("common:notifications.empty")}
</div>
) : (
<NotificationsList>
{data.notifications.map((notification, i) => (
<React.Fragment key={notification.id}>
<NotificationItem
key={notification.id}
notification={{
...notification,
seen: Number(!unseenIds.has(notification.id)),
}}
/>
{i !== data.notifications.length - 1 && (
<NotificationItemDivider />
)}
</React.Fragment>
))}
</NotificationsList>
)}
<div className="text-xs text-lighter mt-6">
{t("common:notifications.fullList.explanation")}
</div>
</Main>
);
}

View File

@ -17,6 +17,7 @@ import {
import type { UserWithPlusTier } from "~/db/types";
import { useUser } from "~/features/auth/core/user";
import { requireUser } from "~/features/auth/core/user.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
import {
nextNonCompletedVoting,
@ -90,6 +91,16 @@ export const action: ActionFunction = async ({ request }) => {
...votingMonthYear,
});
notify({
userIds: [suggested.id],
notification: {
type: "PLUS_SUGGESTION_ADDED",
meta: {
tier: data.tier,
},
},
});
throw redirect(plusSuggestionPage({ tier: data.tier }));
};

View File

@ -14,7 +14,7 @@ export const loader: LoaderFunction = async ({ request }) => {
return json<PlusListLoaderData>({
users: Object.fromEntries(
(await UserRepository.findAllPlusMembers()).map((u) => [
(await UserRepository.findAllPlusServerMembers()).map((u) => [
u.discordId,
u.plusTier,
]),

View File

@ -17,10 +17,11 @@ import { NewTabs } from "~/components/NewTabs";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { getUser, requireUser } from "~/features/auth/core/user.server";
import * as NotificationService from "~/features/chat/NotificationService.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { Chat, useChat } from "~/features/chat/components/Chat";
import { currentOrPreviousSeason } from "~/features/mmr/season";
import { userSkills } from "~/features/mmr/tiered.server";
import { notify } from "~/features/notifications/core/notify.server";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
import * as QRepository from "~/features/sendouq/QRepository.server";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
@ -140,7 +141,7 @@ export const action: ActionFunction = async ({ request }) => {
const targetChatCode = chatCodeByGroupId(data.targetGroupId);
if (targetChatCode) {
NotificationService.notify({
ChatSystemMessage.send({
room: targetChatCode,
type: "LIKE_RECEIVED",
revalidateOnly: true,
@ -159,7 +160,7 @@ export const action: ActionFunction = async ({ request }) => {
const targetChatCode = chatCodeByGroupId(data.targetGroupId);
if (targetChatCode) {
NotificationService.notify({
ChatSystemMessage.send({
room: targetChatCode,
type: "LIKE_RECEIVED",
revalidateOnly: true,
@ -224,7 +225,7 @@ export const action: ActionFunction = async ({ request }) => {
refreshGroup(survivingGroupId);
if (ourGroup.chatCode && theirGroup.chatCode) {
NotificationService.notify([
ChatSystemMessage.send([
{
room: ourGroup.chatCode,
type: "NEW_GROUP",
@ -310,7 +311,7 @@ export const action: ActionFunction = async ({ request }) => {
});
if (ourGroup.chatCode && theirGroup.chatCode) {
NotificationService.notify([
ChatSystemMessage.send([
{
room: ourGroup.chatCode,
type: "MATCH_STARTED",
@ -324,6 +325,20 @@ export const action: ActionFunction = async ({ request }) => {
]);
}
notify({
userIds: [
...ourGroup.members.map((m) => m.id),
...theirGroup.members.map((m) => m.id),
],
defaultSeenUserIds: [user.id],
notification: {
type: "SQ_NEW_MATCH",
meta: {
matchId: createdMatch.id,
},
},
});
throw redirect(sendouQMatchPage(createdMatch.id));
}
case "GIVE_MANAGER": {
@ -364,7 +379,7 @@ export const action: ActionFunction = async ({ request }) => {
const targetChatCode = chatCodeByGroupId(currentGroup.id);
if (targetChatCode) {
NotificationService.notify({
ChatSystemMessage.send({
room: targetChatCode,
type: "USER_LEFT",
context: { name: user.username },

View File

@ -39,7 +39,7 @@ import { sql } from "~/db/sql";
import type { GroupMember, ReportedWeapon } from "~/db/types";
import { useUser } from "~/features/auth/core/user";
import { getUserId, requireUser } from "~/features/auth/core/user.server";
import * as NotificationService from "~/features/chat/NotificationService.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import type { ChatMessage } from "~/features/chat/chat-types";
import { Chat, type ChatProps, useChat } from "~/features/chat/components/Chat";
import { currentOrPreviousSeason, currentSeason } from "~/features/mmr/season";
@ -326,7 +326,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED";
};
NotificationService.notify({
ChatSystemMessage.send({
room: match.chatCode,
type: type(),
context: {

View File

@ -10,6 +10,7 @@ import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { getUser, requireUser } from "~/features/auth/core/user.server";
import { currentSeason } from "~/features/mmr/season";
import { notify } from "~/features/notifications/core/notify.server";
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
import * as QRepository from "~/features/sendouq/QRepository.server";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
@ -111,11 +112,22 @@ export const action = async ({ request }: ActionFunctionArgs) => {
userId: data.id,
role: "MANAGER",
});
await QRepository.refreshTrust({
trustGiverUserId: data.id,
trustReceiverUserId: user.id,
});
notify({
userIds: [data.id],
notification: {
type: "SQ_ADDED_TO_GROUP",
meta: {
adderUsername: user.username,
},
},
});
return null;
}
default: {

View File

@ -1,6 +1,8 @@
import type { MetaFunction } from "@remix-run/node";
import { useFetcher, useNavigate, useSearchParams } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "~/components/Button";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@ -11,6 +13,8 @@ import { languages } from "~/modules/i18n/config";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { SETTINGS_PAGE, navIconUrl } from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button";
import { SendouPopover } from "../../../components/elements/Popover";
import { action } from "../actions/settings.server";
export { action };
@ -32,20 +36,25 @@ export default function SettingsPage() {
<h2 className="text-lg">{t("common:pages.settings")}</h2>
<LanguageSelector />
<ThemeSelector />
<div className="mt-6 stack md">
<PreferenceSelectorSwitch
_action="UPDATE_DISABLE_BUILD_ABILITY_SORTING"
defaultSelected={
user?.preferences.disableBuildAbilitySorting ?? false
}
label={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label",
)}
bottomText={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText",
)}
/>
</div>
{user ? (
<>
<PushNotificationsEnabler />
<div className="mt-6 stack md">
<PreferenceSelectorSwitch
_action="UPDATE_DISABLE_BUILD_ABILITY_SORTING"
defaultSelected={
user?.preferences.disableBuildAbilitySorting ?? false
}
label={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label",
)}
bottomText={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText",
)}
/>
</div>
</>
) : null}
</div>
</Main>
);
@ -124,6 +133,97 @@ function ThemeSelector() {
);
}
// adapted from https://pqvst.com/2023/11/21/web-push-notifications/
function PushNotificationsEnabler() {
const { t } = useTranslation(["common"]);
const [notificationsPermsGranted, setNotificationsPermsGranted] =
React.useState<NotificationPermission | "not-supported">("default");
React.useEffect(() => {
if (!("serviceWorker" in navigator)) {
// Service Worker isn't supported on this browser, disable or hide UI.
setNotificationsPermsGranted("not-supported");
return;
}
if (!("PushManager" in window)) {
// Push isn't supported on this browser, disable or hide UI.
setNotificationsPermsGranted("not-supported");
return;
}
setNotificationsPermsGranted(Notification.permission);
}, []);
function askPermission() {
Notification.requestPermission().then((permission) => {
setNotificationsPermsGranted(permission);
if (permission === "granted") {
initServiceWorker();
}
});
}
async function initServiceWorker() {
const swRegistration = await navigator.serviceWorker.register("sw.js");
const subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
sendSubscriptionToServer(subscription);
} else {
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
});
sendSubscriptionToServer(subscription);
}
}
function sendSubscriptionToServer(subscription: PushSubscription) {
fetch("/notifications/subscribe", {
method: "post",
body: JSON.stringify(subscription),
headers: { "content-type": "application/json" },
});
}
return (
<div>
<Label>{t("common:settings.notifications.title")}</Label>
{notificationsPermsGranted === "granted" ? (
<SendouPopover
trigger={
<SendouButton size="small" variant="minimal">
{t("common:actions.disable")}
</SendouButton>
}
>
{t("common:settings.notifications.disableInfo")}
</SendouPopover>
) : notificationsPermsGranted === "not-supported" ||
notificationsPermsGranted === "denied" ? (
<SendouPopover
trigger={
<SendouButton size="small" variant="minimal">
{t("common:actions.enable")}
</SendouButton>
}
>
{notificationsPermsGranted === "not-supported"
? t("common:settings.notifications.browserNotSupported")
: t("common:settings.notifications.permissionDenied")}
</SendouPopover>
) : (
<Button size="tiny" variant="minimal" onClick={askPermission}>
{t("common:actions.enable")}
</Button>
)}
<FormMessage type="info">
{t("common:settings.notifications.description")}
</FormMessage>
</div>
);
}
function PreferenceSelectorSwitch({
_action,
label,

View File

@ -29,6 +29,7 @@ import {
} from "~/features/mmr/mmr-utils.server";
import { currentSeason } from "~/features/mmr/season";
import { refreshUserSkills } from "~/features/mmr/tiered.server";
import { notify } from "~/features/notifications/core/notify.server";
import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
@ -157,6 +158,21 @@ export const action: ActionFunction = async ({ params, request }) => {
}
})();
notify({
userIds: seeding.flatMap((tournamentTeamId) =>
tournament.teamById(tournamentTeamId)!.members.map((m) => m.userId),
),
notification: {
type: "TO_BRACKET_STARTED",
meta: {
tournamentId,
bracketIdx: data.bracketIdx,
bracketName: bracket.name,
tournamentName: tournament.ctx.name,
},
},
});
break;
}
case "PREPARE_MAPS": {

View File

@ -1,8 +1,9 @@
import type { ActionFunction } from "@remix-run/node";
import { z } from "zod";
import { requireUserId } from "~/features/auth/core/user.server";
import { requireUser } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import {
clearTournamentDataCache,
@ -30,7 +31,7 @@ import { tournamentIdFromParams } from "../tournament-utils";
import { inGameNameIfNeeded } from "../tournament-utils.server";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const user = await requireUser(request);
const data = await parseRequestPayload({
request,
schema: adminActionSchema,
@ -230,6 +231,22 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
});
notify({
userIds: [data.userId],
notification: {
type: "TO_ADDED_TO_TEAM",
pictureUrl:
tournament.tournamentTeamLogoSrc(team) ?? tournament.ctx.logoSrc,
meta: {
adderUsername: user.username,
teamName: team.name,
tournamentId,
tournamentName: tournament.ctx.name,
tournamentTeamId: team.id,
},
},
});
break;
}
case "DELETE_TEAM": {

View File

@ -2,6 +2,7 @@ import type { ActionFunction } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.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 * as QRepository from "~/features/sendouq/QRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import {
@ -250,6 +251,21 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
});
notify({
userIds: [data.userId],
notification: {
type: "TO_ADDED_TO_TEAM",
meta: {
adderUsername: user.username,
tournamentId,
teamName: ownTeam.name,
tournamentName: tournament.ctx.name,
tournamentTeamId: ownTeam.id,
},
pictureUrl: tournament.logoSrc,
},
});
break;
}
case "UNREGISTER": {

View File

@ -33,10 +33,7 @@ import {
} from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import { streamsByTournamentId } from "../core/streams.server";
import {
HACKY_resolvePicture,
tournamentIdFromParams,
} from "../tournament-utils";
import { tournamentIdFromParams } from "../tournament-utils";
import "../tournament.css";
import "~/styles/maps.css";
@ -92,9 +89,7 @@ export const handle: SendouRouteHandle = {
}
: null,
{
imgPath: data.tournament.ctx.logoUrl
? userSubmittedImage(data.tournament.ctx.logoUrl)
: HACKY_resolvePicture(data.tournament.ctx),
imgPath: data.tournament.ctx.logoSrc,
href: tournamentPage(data.tournament.ctx.id),
type: "IMAGE" as const,
text: data.tournament.ctx.name,

View File

@ -319,7 +319,7 @@ export function findAllPatrons() {
.execute();
}
export function findAllPlusMembers() {
export function findAllPlusServerMembers() {
return db
.selectFrom("User")
.innerJoin("PlusTier", "PlusTier.userId", "User.id")

View File

@ -1,15 +1,19 @@
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { EventsWithMapPoolsLoaderData } from "~/features/calendar/routes/map-pool-events";
import type { PatronsListLoaderData } from "~/features/front-page/routes/patrons-list";
import type { NotificationsLoaderData } from "~/features/notifications/routes/notifications.peek";
import type { TrustersLoaderData } from "~/features/sendouq/routes/trusters";
import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import {
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
GET_TRUSTERS_ROUTE,
NOTIFICATIONS_PEAK_ROUTE,
PATRONS_LIST_ROUTE,
getWeaponUsage,
} from "~/utils/urls";
import { useUser } from "../features/auth/core/user";
// TODO: replace with useFetcher after proper errr handling is implemented https://github.com/remix-run/react-router/discussions/10013
@ -79,3 +83,18 @@ export function usePatrons() {
isError: error,
};
}
export function useNotifications() {
const user = useUser();
const { data, error, mutate, isValidating } = useSWR<NotificationsLoaderData>(
user ? NOTIFICATIONS_PEAK_ROUTE : null,
fetcher(NOTIFICATIONS_PEAK_ROUTE),
);
return {
notifications: data?.notifications,
isLoading: isValidating || (!error && !data),
isError: error,
refresh: () => mutate(),
};
}

View File

@ -75,6 +75,7 @@ export const meta: MetaFunction = (args) => {
};
export type RootLoaderData = SerializeFrom<typeof loader>;
export type LoggedInUser = NonNullable<RootLoaderData["user"]>;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUser(request, false);

View File

@ -9,6 +9,20 @@ export default [
index("features/front-page/routes/index.tsx"),
route("/patrons-list", "features/front-page/routes/patrons-list.ts"),
route("/notifications", "features/notifications/routes/notifications.tsx"),
route(
"/notifications/peek",
"features/notifications/routes/notifications.peek.ts",
),
route(
"/notifications/seen",
"features/notifications/routes/notifications.seen.ts",
),
route(
"/notifications/subscribe",
"features/notifications/routes/notifications.subscribe.ts",
),
route("/settings", "features/settings/routes/settings.tsx"),
route("/suspended", "features/ban/routes/suspended.tsx"),

View File

@ -0,0 +1,11 @@
import * as NotificationRepository from "../features/notifications/NotificationRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const DeleteOldNotificationsRoutine = new Routine({
name: "DeleteOldNotifications",
func: async () => {
const { numDeletedRows } = await NotificationRepository.deleteOld();
logger.info(`Deleted ${numDeletedRows} old notifications`);
},
});

View File

@ -0,0 +1,11 @@
import * as QRepository from "~/features/sendouq/QRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const DeleteOldTrustRoutine = new Routine({
name: "DeleteOldTrusts",
func: async () => {
const { numDeletedRows } = await QRepository.deleteOldTrust();
logger.info(`Deleted ${numDeletedRows} old trusts`);
},
});

View File

@ -0,0 +1,23 @@
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldTrustRoutine } from "./deleteOldTrusts";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting";
import { NotifySeasonStartRoutine } from "./notifySeasonStart";
import { SetOldGroupsAsInactiveRoutine } from "./setOldGroupsAsInactive";
import { UpdatePatreonDataRoutine } from "./updatePatreonData";
/** List of Routines that should occur hourly at XX:00 */
export const everyHourAt00 = [
NotifySeasonStartRoutine,
NotifyPlusServerVotingRoutine,
NotifyCheckInStartRoutine,
];
/** List of Routines that should occur hourly at XX:30 */
export const everyHourAt30 = [
SetOldGroupsAsInactiveRoutine,
UpdatePatreonDataRoutine,
];
/** List of Routines that should occur daily */
export const daily = [DeleteOldTrustRoutine, DeleteOldNotificationsRoutine];

View File

@ -0,0 +1,42 @@
import * as CalendarRepository from "../features/calendar/CalendarRepository.server";
import { notify } from "../features/notifications/core/notify.server";
import { tournamentDataCached } from "../features/tournament-bracket/core/Tournament.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const NotifyCheckInStartRoutine = new Routine({
name: "NotifyCheckInStart",
func: async () => {
const now = new Date();
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
const tournaments = await CalendarRepository.findAllBetweenTwoTimestamps({
startTime: now,
endTime: oneHourFromNow,
onlyTournaments: true,
});
for (const { tournamentId } of tournaments) {
const tournament = await tournamentDataCached({
tournamentId: tournamentId!,
user: undefined,
});
logger.info(
`Notifying check-in start for tournament ${tournament.ctx.id}`,
);
await notify({
notification: {
type: "TO_CHECK_IN_OPENED",
meta: {
tournamentId: tournament.ctx.id,
tournamentName: tournament.ctx.name,
},
pictureUrl: tournament.ctx.logoSrc,
},
userIds: tournament.ctx.teams
.flatMap((team) => team.members.map((member) => member.userId))
.concat(tournament.ctx.staff.map((staff) => staff.id)),
});
}
},
});

View File

@ -0,0 +1,39 @@
import { currentSeason } from "../features/mmr/season";
import * as NotificationRepository from "../features/notifications/NotificationRepository.server";
import { notify } from "../features/notifications/core/notify.server";
import { isVotingActive } from "../features/plus-voting/core";
import * as UserRepository from "../features/user-page/UserRepository.server";
import { Routine } from "./routine.server";
export const NotifyPlusServerVotingRoutine = new Routine({
name: "NotifyPlusServerVoting",
func: async () => {
if (!isVotingActive()) return;
const season = currentSeason(new Date())!;
const plusVotingNotifications = await NotificationRepository.findAllByType(
"PLUS_VOTING_STARTED",
);
if (
plusVotingNotifications.some(
(notification) => notification.meta.seasonNth === season.nth,
)
) {
return;
}
await notify({
notification: {
type: "PLUS_VOTING_STARTED",
meta: {
seasonNth: season.nth,
},
},
userIds: (await UserRepository.findAllPlusServerMembers()).map(
(member) => member.id,
),
});
},
});

View File

@ -0,0 +1,37 @@
import { currentSeason } from "../features/mmr/season";
import { userSkills } from "../features/mmr/tiered.server";
import * as NotificationRepository from "../features/notifications/NotificationRepository.server";
import { notify } from "../features/notifications/core/notify.server";
import { Routine } from "./routine.server";
export const NotifySeasonStartRoutine = new Routine({
name: "NotifySeasonStart",
func: async () => {
const season = currentSeason(new Date());
if (!season) return;
const seasonNotifications =
await NotificationRepository.findAllByType("SEASON_STARTED");
if (
seasonNotifications.some(
(notification) => notification.meta.seasonNth === season.nth,
)
) {
return;
}
const lastSeasonsUsers = userSkills(season.nth - 1).userSkills;
await notify({
notification: {
type: "SEASON_STARTED",
meta: {
seasonNth: season.nth,
},
},
userIds: Object.keys(lastSeasonsUsers).map(Number),
});
},
});

View File

@ -0,0 +1,30 @@
import { logger } from "../utils/logger";
export class Routine {
private name;
private func;
constructor({
name,
func,
}: {
name: string;
func: () => Promise<void>;
}) {
this.name = name;
this.func = func;
}
async run() {
logger.info(`Running routine: ${this.name}`);
const startTime = performance.now();
try {
await this.func();
} catch (error) {
logger.error(`Error running routine ${this.name}: ${error}`);
return;
}
const endTime = performance.now();
logger.info(`Routine ${this.name} completed in ${endTime - startTime}ms`);
}
}

View File

@ -0,0 +1,11 @@
import * as QRepository from "~/features/sendouq/QRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const SetOldGroupsAsInactiveRoutine = new Routine({
name: "SetOldGroupsAsInactive",
func: async () => {
const { numUpdatedRows } = await QRepository.setOldGroupsAsInactive();
logger.info(`Set ${numUpdatedRows} as inactive`);
},
});

View File

@ -0,0 +1,9 @@
import { updatePatreonData } from "../modules/patreon";
import { Routine } from "./routine.server";
export const UpdatePatreonDataRoutine = new Routine({
name: "UpdatePatreonData",
func: async () => {
await updatePatreonData();
},
});

View File

@ -390,7 +390,7 @@
}
.layout__breadcrumb-container > a {
max-width: 175px;
max-width: 90px;
}
.layout__footer__socials {

View File

@ -226,6 +226,10 @@
margin-block-start: var(--s-1);
}
.mt-1-5 {
margin-block-start: var(--s-1-5);
}
.mt-2 {
margin-block-start: var(--s-2);
}

39
app/utils/arrays.test.ts Normal file
View File

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { diff } from "./arrays";
describe("diff", () => {
it("should return elements in arr2 but not in arr1", () => {
const arr1 = [1, 2, 3];
const arr2 = [2, 3, 4, 4];
const result = diff(arr1, arr2);
expect(result).toEqual([4, 4]);
});
it("should return an empty array if arr2 is empty", () => {
const arr1 = [1, 2, 3];
const arr2: number[] = [];
const result = diff(arr1, arr2);
expect(result).toEqual([]);
});
it("should return all elements of arr2 if arr1 is empty", () => {
const arr1: number[] = [];
const arr2 = [1, 2, 3];
const result = diff(arr1, arr2);
expect(result).toEqual([1, 2, 3]);
});
it("should handle arrays with duplicate elements", () => {
const arr1 = [1, 2, 2, 3];
const arr2 = [2, 2, 3, 3, 4];
const result = diff(arr1, arr2);
expect(result).toEqual([3, 4]);
});
it("should return an empty array if both arrays are the same", () => {
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const result = diff(arr1, arr2);
expect(result).toEqual([]);
});
});

View File

@ -108,3 +108,35 @@ export function nullifyingAvg(values: number[]) {
if (values.length === 0) return null;
return values.reduce((acc, cur) => acc + cur, 0) / values.length;
}
export function countElements<T>(arr: T[]): Map<T, number> {
const counts = new Map<T, number>();
for (const element of arr) {
const count = counts.get(element) ?? 0;
counts.set(element, count + 1);
}
return counts;
}
/** Returns list of elements that are in arr2 but not in arr1. Supports duplicates */
export function diff<T extends string | number>(arr1: T[], arr2: T[]): T[] {
const arr1Counts = countElements(arr1);
const arr2Counts = countElements(arr2);
const diff = new Map<T, number>();
for (const [element, count] of arr2Counts) {
const diffCount = Math.max(count - (arr1Counts.get(element) ?? 0), 0);
diff.set(element, diffCount);
}
const result: T[] = [];
for (const [element, count] of diff) {
result.push(...new Array(count).fill(element));
}
return result;
}

View File

@ -127,6 +127,7 @@ export const SUSPENDED_PAGE = "/suspended";
export const LFG_PAGE = "/lfg";
export const SETTINGS_PAGE = "/settings";
export const LUTI_PAGE = "/luti";
export const PLUS_VOTING_PAGE = "/plus/voting";
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
export const COMMON_PREVIEW_IMAGE =
@ -148,6 +149,10 @@ export const GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE = "/calendar/map-pool-events";
export const GET_TRUSTERS_ROUTE = "/trusters";
export const PATRONS_LIST_ROUTE = "/patrons-list";
export const NOTIFICATIONS_URL = "/notifications";
export const NOTIFICATIONS_PEAK_ROUTE = "/notifications/peek";
export const NOTIFICATIONS_MARK_AS_SEEN_ROUTE = "/notifications/seen";
interface UserLinkArgs {
discordId: User["discordId"];
customUrl?: User["customUrl"];
@ -177,8 +182,12 @@ export const newVodPage = (vodToEditId?: number) =>
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
`${userResultsPage(user)}/highlights`;
export const artPage = (tag?: string) => `/art${tag ? `?tag=${tag}` : ""}`;
export const userArtPage = (user: UserLinkArgs, source?: ArtSource) =>
`${userPage(user)}/art${source ? `?source=${source}` : ""}`;
export const userArtPage = (
user: UserLinkArgs,
source?: ArtSource,
bigArtId?: number,
) =>
`${userPage(user)}/art${source ? `?source=${source}` : ""}${bigArtId ? `?big=${bigArtId}` : ""}`;
export const newArtPage = (artId?: Art["id"]) =>
`${artPage()}/new${artId ? `?art=${artId}` : ""}`;
export const userNewBuildPage = (

View File

@ -48,6 +48,9 @@
},
"linter": {
"enabled": true
},
"parser": {
"cssModules": true
}
}
}

Binary file not shown.

43
e2e/badges.spec.ts Normal file
View File

@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";
import { impersonate, navigate, seed, selectUser } from "~/utils/playwright";
import { badgePage } from "~/utils/urls";
import { NZAP_TEST_ID } from "../app/db/seed/constants";
test.describe("Badges", () => {
test("adds a badge sending a notification", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: badgePage(1),
});
await page.getByRole("link", { name: "Edit", exact: true }).click();
await selectUser({
page,
userName: "N-ZAP",
labelName: "Add new owner",
});
await page.getByRole("button", { name: "Save", exact: true }).click();
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: "/",
});
await page.getByTestId("notifications-button").click();
await page.getByText("New badge (4v4 Sundaes)").click();
await expect(page).toHaveURL(badgePage(1));
await page.getByTestId("notifications-button").click();
await page.getByText("See all").click();
await expect(
page.getByRole("heading", { name: "Notifications" }),
).toBeVisible();
});
});

View File

@ -41,6 +41,35 @@
"header.adder.vod": "VoD",
"header.adder.plusSuggestion": "Plus suggestion",
"notifications.title": "Notifications",
"notifications.empty": "None yet, check back later",
"notifications.loading": "Loading...",
"notifications.seeAll": "See all",
"notifications.fullList.explanation": "Max of 100 notifications shown. Notifications older than 14 days are automatically deleted.",
"notifications.managePush": "Push notifications",
"notifications.title.SQ_ADDED_TO_GROUP": "Added to SendouQ Group",
"notifications.text.SQ_ADDED_TO_GROUP": "Added to a group by {{adderUsername}}",
"notifications.title.SQ_NEW_MATCH": "New SendouQ Match",
"notifications.text.SQ_NEW_MATCH": "SendouQ match #{{matchId}} started",
"notifications.title.TO_ADDED_TO_TEAM": "Added to Team",
"notifications.text.TO_ADDED_TO_TEAM": "Added to a team ({{teamName}}) by {{adderUsername}} in {{tournamentName}}",
"notifications.title.TO_BRACKET_STARTED": "Bracket Started",
"notifications.text.TO_BRACKET_STARTED": "{{bracketName}} of {{tournamentName}} started",
"notifications.title.TO_CHECK_IN_OPENED": "Check-in Opened",
"notifications.text.TO_CHECK_IN_OPENED": "Check-in for {{tournamentName}} open",
"notifications.title.BADGE_ADDED": "New Badge",
"notifications.text.BADGE_ADDED": "New badge ({{badgeName}})",
"notifications.title.BADGE_MANAGER_ADDED": "Manager Rights Granted",
"notifications.text.BADGE_MANAGER_ADDED": "You got manager rights for badge {{badgeName}}",
"notifications.title.PLUS_VOTING_STARTED": "Plus Voting Started",
"notifications.text.PLUS_VOTING_STARTED": "Plus Server voting of season {{seasonNth}} started",
"notifications.title.PLUS_SUGGESTION_ADDED": "Plus Suggestion Added",
"notifications.text.PLUS_SUGGESTION_ADDED": "You were suggested to +{{tier}}",
"notifications.title.TAGGED_TO_ART": "Tagged in Art",
"notifications.text.TAGGED_TO_ART": "Tagged in art by {{adderUsername}}",
"notifications.title.SEASON_STARTED": "New Season Started",
"notifications.text.SEASON_STARTED": "SendouQ is open again for Season {{seasonNth}}",
"auth.errors.aborted": "Login Aborted",
"auth.errors.failed": "Login Failed",
"auth.errors.discordPermissions": "For your sendou.ink profile, the site needs access to your Discord profile's name, avatar and social connections.",
@ -79,6 +108,8 @@
"actions.upload": "Upload",
"actions.clickHere": "Click here",
"actions.goBack": "Go back",
"actions.enable": "Enable",
"actions.disable": "Disable",
"maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ",
@ -246,5 +277,10 @@
"fc.title": "Friend code",
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label": "Builds: Disable automatic ability sorting",
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "Outside of your profile page, build abilities are sorted so that same abilities are next to each other. This setting allows you to see the abilities in the order they were authored everywhere."
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "Outside of your profile page, build abilities are sorted so that same abilities are next to each other. This setting allows you to see the abilities in the order they were authored everywhere.",
"settings.notifications.title": "Push notifications",
"settings.notifications.description": "Receive push notifications to your device even if you don't currently have sendou.ink open.",
"settings.notifications.disableInfo": "To disable push notifications check your browser settings",
"settings.notifications.browserNotSupported": "Push notifications are not supported on this browser",
"settings.notifications.permissionDenied": "Push notifications were denied. Check your browser settings to re-enable"
}

View File

@ -0,0 +1,51 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/*sql*/ `
create table "Notification" (
"id" integer primary key,
"type" text not null,
"meta" text,
"pictureUrl" text,
"createdAt" integer default (strftime('%s', 'now')) not null
) strict
`,
).run();
db.prepare(
/*sql*/ `create index notification_type on "Notification"("type")`,
).run();
db.prepare(
/*sql*/ `
create table "NotificationUser" (
"notificationId" integer not null,
"userId" integer not null,
"seen" integer default 0 not null,
unique("notificationId", "userId"),
foreign key ("notificationId") references "Notification"("id") on delete cascade,
foreign key ("userId") references "User"("id") on delete cascade
) strict
`,
).run();
db.prepare(
/*sql*/ `create index notification_user_id on "NotificationUser"("userId")`,
).run();
db.prepare(
/*sql*/ `
create table "NotificationUserSubscription" (
"id" integer primary key,
"userId" integer not null,
"subscription" text not null,
foreign key ("userId") references "User"("id") on delete cascade
) strict
`,
).run();
db.prepare(
/*sql*/ `create index notification_push_url_user_id on "NotificationUserSubscription"("userId")`,
).run();
})();
}

3421
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -62,6 +62,7 @@
"node-cron": "3.0.3",
"nprogress": "^0.2.0",
"openskill": "^4.1.0",
"p-limit": "^6.2.0",
"react": "^18.3.1",
"react-aria-components": "^1.6.0",
"react-charts": "^3.0.0-beta.57",
@ -77,9 +78,10 @@
"remix-auth": "^4.1.0",
"remix-auth-oauth2": "^3.2.2",
"remix-i18next": "^6.4.1",
"remix-utils": "^8.1.0",
"remix-utils": "^7.7.0",
"slugify": "^1.6.6",
"swr": "^2.3.2",
"web-push": "^3.6.7",
"zod": "^3.24.2"
},
"devDependencies": {
@ -93,6 +95,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-responsive-masonry": "^2.1.3",
"@types/web-push": "^3.6.4",
"cross-env": "^7.0.3",
"ley": "^0.8.1",
"sql-formatter": "^15.4.11",

View File

@ -1,3 +1,30 @@
self.addEventListener("fetch", () => {
return;
});
self.addEventListener("push", (event) => {
const { title, ...options } = JSON.parse(event.data.text());
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
const targetUrl = event.notification.data.url;
event.notification.close(); // Android needs explicit close.
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
// Check if there is already a window/tab open with the target URL
for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i];
// If so, just focus it.
if (client.url === targetUrl && "focus" in client) {
return client.focus();
}
}
// If not, then open the target URL in a new window/tab.
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
}),
);
});