mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
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:
parent
f711fd718b
commit
4ff0586ff8
|
|
@ -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=
|
||||
|
|
|
|||
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
17
app/components/icons/Bell.tsx
Normal file
17
app/components/icons/Bell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
app/components/layout/NotificationPopover.module.css
Normal file
61
app/components/layout/NotificationPopover.module.css
Normal 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);
|
||||
}
|
||||
139
app/components/layout/NotificationPopover.tsx
Normal file
139
app/components/layout/NotificationPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
122
app/features/notifications/NotificationRepository.server.ts
Normal file
122
app/features/notifications/NotificationRepository.server.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
80
app/features/notifications/components/NotificationList.tsx
Normal file
80
app/features/notifications/components/NotificationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
app/features/notifications/core/notify.server.ts
Normal file
138
app/features/notifications/core/notify.server.ts
Normal 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) },
|
||||
};
|
||||
}
|
||||
18
app/features/notifications/core/webPush.server.ts
Normal file
18
app/features/notifications/core/webPush.server.ts
Normal 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;
|
||||
11
app/features/notifications/loaders/notifications.server.ts
Normal file
11
app/features/notifications/loaders/notifications.server.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
4
app/features/notifications/notifications-contants.ts
Normal file
4
app/features/notifications/notifications-contants.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const NOTIFICATIONS = {
|
||||
PEEK_COUNT: 6,
|
||||
MAX_SHOWN: 100,
|
||||
};
|
||||
20
app/features/notifications/notifications-hooks.ts
Normal file
20
app/features/notifications/notifications-hooks.ts
Normal 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]);
|
||||
}
|
||||
15
app/features/notifications/notifications-schemas.ts
Normal file
15
app/features/notifications/notifications-schemas.ts
Normal 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(),
|
||||
}),
|
||||
});
|
||||
63
app/features/notifications/notifications-types.ts
Normal file
63
app/features/notifications/notifications-types.ts
Normal 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 };
|
||||
74
app/features/notifications/notifications-utils.ts
Normal file
74
app/features/notifications/notifications-utils.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.header {
|
||||
display: flex;
|
||||
gap: var(--s-2-5);
|
||||
}
|
||||
|
||||
.header svg {
|
||||
width: 24px;
|
||||
}
|
||||
19
app/features/notifications/routes/notifications.peek.ts
Normal file
19
app/features/notifications/routes/notifications.peek.ts
Normal 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 };
|
||||
};
|
||||
20
app/features/notifications/routes/notifications.seen.ts
Normal file
20
app/features/notifications/routes/notifications.seen.ts
Normal 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;
|
||||
};
|
||||
20
app/features/notifications/routes/notifications.subscribe.ts
Normal file
20
app/features/notifications/routes/notifications.subscribe.ts
Normal 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;
|
||||
};
|
||||
89
app/features/notifications/routes/notifications.tsx
Normal file
89
app/features/notifications/routes/notifications.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -319,7 +319,7 @@ export function findAllPatrons() {
|
|||
.execute();
|
||||
}
|
||||
|
||||
export function findAllPlusMembers() {
|
||||
export function findAllPlusServerMembers() {
|
||||
return db
|
||||
.selectFrom("User")
|
||||
.innerJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
11
app/routines/deleteOldNotifications.ts
Normal file
11
app/routines/deleteOldNotifications.ts
Normal 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`);
|
||||
},
|
||||
});
|
||||
11
app/routines/deleteOldTrusts.ts
Normal file
11
app/routines/deleteOldTrusts.ts
Normal 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`);
|
||||
},
|
||||
});
|
||||
23
app/routines/list.server.ts
Normal file
23
app/routines/list.server.ts
Normal 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];
|
||||
42
app/routines/notifyCheckInStart.ts
Normal file
42
app/routines/notifyCheckInStart.ts
Normal 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)),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
39
app/routines/notifyPlusServerVoting.ts
Normal file
39
app/routines/notifyPlusServerVoting.ts
Normal 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,
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
37
app/routines/notifySeasonStart.ts
Normal file
37
app/routines/notifySeasonStart.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
30
app/routines/routine.server.ts
Normal file
30
app/routines/routine.server.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
11
app/routines/setOldGroupsAsInactive.ts
Normal file
11
app/routines/setOldGroupsAsInactive.ts
Normal 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`);
|
||||
},
|
||||
});
|
||||
9
app/routines/updatePatreonData.ts
Normal file
9
app/routines/updatePatreonData.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
|
|
@ -390,7 +390,7 @@
|
|||
}
|
||||
|
||||
.layout__breadcrumb-container > a {
|
||||
max-width: 175px;
|
||||
max-width: 90px;
|
||||
}
|
||||
|
||||
.layout__footer__socials {
|
||||
|
|
|
|||
|
|
@ -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
39
app/utils/arrays.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@
|
|||
},
|
||||
"linter": {
|
||||
"enabled": true
|
||||
},
|
||||
"parser": {
|
||||
"cssModules": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
43
e2e/badges.spec.ts
Normal file
43
e2e/badges.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
51
migrations/082-notification.js
Normal file
51
migrations/082-notification.js
Normal 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
3421
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
27
public/sw.js
27
public/sw.js
|
|
@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user