diff --git a/frontend/src/ts/collections/inbox.ts b/frontend/src/ts/collections/inbox.ts index 398068daebdc..06bed51c1e01 100644 --- a/frontend/src/ts/collections/inbox.ts +++ b/frontend/src/ts/collections/inbox.ts @@ -1,7 +1,8 @@ -import { MonkeyMail } from "@monkeytype/schemas/users"; +import { AllRewards, MonkeyMail } from "@monkeytype/schemas/users"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection, + createPacedMutations, eq, MutationFnParams, not, @@ -13,9 +14,17 @@ import { queryClient } from "../queries"; import { baseKey } from "../queries/utils/keys"; import { isAuthenticated } from "../states/core"; import { flushDebounceStrategy } from "./utils/flushDebounceStrategy"; -import { showErrorNotification } from "../states/notifications"; +import { + showErrorNotification, + showSuccessNotification, +} from "../states/notifications"; +import * as BadgeController from "../controllers/badge-controller"; +import { addBadge, addXp } from "../db"; -export const flushStrategy = flushDebounceStrategy({ maxWait: 1000 * 60 * 5 }); +const flushStrategy = flushDebounceStrategy({ maxWait: 1000 * 60 * 5 }); +export function applyPendingInboxActions(): void { + flushStrategy.flush(); +} const queryKeys = { root: () => [...baseKey("inbox", { isUserSpecific: true })], @@ -24,11 +33,10 @@ const queryKeys = { const [maxMailboxSize, setMaxMailboxSize] = createSignal(0); export { maxMailboxSize }; - export type InboxItem = Omit & { status: "unclaimed" | "unread" | "read" | "deleted"; }; -export const inboxCollection = createCollection( +const inboxCollection = createCollection( queryCollectionOptions({ staleTime: 1000 * 60 * 5, queryKey: queryKeys.root(), @@ -58,7 +66,81 @@ export const inboxCollection = createCollection( }), ); -export async function flushPendingChanges({ +export async function refetchInboxCollection(): Promise { + await inboxCollection.utils.refetch(); +} + +const inboxItemIdsToClaim: string[] = []; +export const mutateInboxItem = createPacedMutations< + Pick, + InboxItem +>({ + onMutate: ({ id, status }) => { + inboxCollection.update(id, (old) => { + if (old.status === "unclaimed") { + inboxItemIdsToClaim.push(old.id); + } + old.status = status; + }); + }, + mutationFn: async (changes) => { + await flushPendingChanges(changes); + + const allRewards: AllRewards[] = changes.transaction.mutations + .map((it) => it.modified) + .filter((it) => inboxItemIdsToClaim.includes(it.id)) + .flatMap((it) => it.rewards); + inboxItemIdsToClaim.length = 0; + claimRewards(allRewards); + }, + strategy: flushStrategy.strategy, +}); + +function claimRewards(pendingRewards: AllRewards[]): void { + if (pendingRewards.length === 0) return; + + let totalXp = 0; + const badgeNames: string[] = []; + for (const reward of pendingRewards) { + if (reward.type === "xp") { + totalXp += reward.item; + } else if (reward.type === "badge") { + const badge = BadgeController.getById(reward.item.id); + if (badge) { + badgeNames.push(badge.name); + addBadge(reward.item); + } + } + } + if (totalXp > 0) { + addXp(totalXp); + } + + if (badgeNames.length > 0) { + showSuccessNotification( + `New badge${badgeNames.length > 1 ? "s" : ""} unlocked: ${badgeNames.join(", ")}`, + { durationMs: 5000, customTitle: "Reward", customIcon: "gift" }, + ); + } +} + +export function claimAllInboxItems(): void { + inboxCollection.forEach((it) => { + if (it.status === "unclaimed") { + mutateInboxItem({ id: it.id, status: "read" }); + } + }); +} + +export function deleteAllInboxItems(): void { + inboxCollection.forEach((it) => { + if (it.status === "unread" || it.status === "read") { + mutateInboxItem({ id: it.id, status: "deleted" }); + } + }); +} + +async function flushPendingChanges({ transaction, }: MutationFnParams): Promise { const updatedStatus = Object.groupBy( @@ -84,6 +166,9 @@ export async function flushPendingChanges({ updatedStatus.deleted?.forEach((deleted) => inboxCollection.utils.writeDelete(deleted.id), ); + updatedStatus.read?.forEach((read) => { + inboxCollection.utils.writeUpdate(read); + }); }); return { refetch: false }; diff --git a/frontend/src/ts/components/modals/DevOptionsModal.tsx b/frontend/src/ts/components/modals/DevOptionsModal.tsx index a0913340d417..e758460bf855 100644 --- a/frontend/src/ts/components/modals/DevOptionsModal.tsx +++ b/frontend/src/ts/components/modals/DevOptionsModal.tsx @@ -3,7 +3,7 @@ import { envConfig } from "virtual:env-config"; import Ape from "../../ape"; import { signIn } from "../../auth"; -import { inboxCollection } from "../../collections/inbox"; +import { refetchInboxCollection } from "../../collections/inbox"; import { addXp } from "../../db"; import { toggleCaretDebug } from "../../elements/caret"; import { getInputElement } from "../../input/input-element"; @@ -177,7 +177,7 @@ export function DevOptionsModal(): JSXElement { return; } showSuccessNotification("Debug inbox item added"); - void inboxCollection.utils.refetch(); + void refetchInboxCollection(); }); }; diff --git a/frontend/src/ts/components/popups/alerts/AlertsPopup.tsx b/frontend/src/ts/components/popups/alerts/AlertsPopup.tsx index 7b78bfccaba7..cac8561a3a44 100644 --- a/frontend/src/ts/components/popups/alerts/AlertsPopup.tsx +++ b/frontend/src/ts/components/popups/alerts/AlertsPopup.tsx @@ -1,6 +1,6 @@ import { JSXElement } from "solid-js"; -import { flushStrategy } from "../../../collections/inbox"; +import { applyPendingInboxActions } from "../../../collections/inbox"; import { hideModalAndClearChain } from "../../../states/modals"; import { AnimatedModal } from "../../common/AnimatedModal"; import { Button } from "../../common/Button"; @@ -30,7 +30,7 @@ export function AlertsPopup(): JSXElement { onBackdropClick={() => hideModalAndClearChain("Alerts")} afterHide={() => { setTimeout(() => { - flushStrategy.flush(); + applyPendingInboxActions(); }, 125); }} > diff --git a/frontend/src/ts/components/popups/alerts/Inbox.tsx b/frontend/src/ts/components/popups/alerts/Inbox.tsx index ecdf629cdac6..07a00c9b04c9 100644 --- a/frontend/src/ts/components/popups/alerts/Inbox.tsx +++ b/frontend/src/ts/components/popups/alerts/Inbox.tsx @@ -1,20 +1,16 @@ -import { AllRewards } from "@monkeytype/schemas/users"; -import { createPacedMutations } from "@tanstack/solid-db"; import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict"; import { createEffect, For, JSXElement, Show } from "solid-js"; import { - flushPendingChanges, - flushStrategy, - inboxCollection, + claimAllInboxItems, + deleteAllInboxItems, InboxItem, maxMailboxSize, + mutateInboxItem, useInboxQuery, } from "../../../collections/inbox"; -import * as BadgeController from "../../../controllers/badge-controller"; -import { addBadge, addXp, updateInboxUnreadSize } from "../../../db"; +import { updateInboxUnreadSize } from "../../../db"; import { getModalVisibility } from "../../../states/modals"; -import { showSuccessNotification } from "../../../states/notifications"; import { cn } from "../../../utils/cn"; import AsyncContent from "../../common/AsyncContent"; import { Button } from "../../common/Button"; @@ -23,40 +19,11 @@ import { H3 } from "../../common/Headers"; import { LoadingCircle } from "../../common/LoadingCircle"; import { AlertsSection } from "./AlertsSection"; -const inboxItemIdsToClaim: string[] = []; export function Inbox(): JSXElement { const inboxQuery = useInboxQuery( () => getModalVisibility("Alerts")?.visible ?? false, ); - const claimRewards = (pendingRewards: AllRewards[]) => { - if (pendingRewards.length === 0) return; - - let totalXp = 0; - const badgeNames: string[] = []; - for (const reward of pendingRewards) { - if (reward.type === "xp") { - totalXp += reward.item; - } else if (reward.type === "badge") { - const badge = BadgeController.getById(reward.item.id); - if (badge) { - badgeNames.push(badge.name); - addBadge(reward.item); - } - } - } - if (totalXp > 0) { - addXp(totalXp); - } - - if (badgeNames.length > 0) { - showSuccessNotification( - `New badge${badgeNames.length > 1 ? "s" : ""} unlocked: ${badgeNames.join(", ")}`, - { durationMs: 5000, customTitle: "Reward", customIcon: "gift" }, - ); - } - }; - createEffect(() => { const items = inboxQuery(); const count = items.filter( @@ -65,42 +32,6 @@ export function Inbox(): JSXElement { updateInboxUnreadSize(count); }); - const mutate = createPacedMutations< - Pick, - InboxItem - >({ - onMutate: ({ id, status }) => { - inboxCollection.update(id, (old) => { - if (old.status === "unclaimed") { - inboxItemIdsToClaim.push(old.id); - } - old.status = status; - }); - }, - mutationFn: async (changes) => { - await flushPendingChanges(changes); - - const allRewards: AllRewards[] = changes.transaction.mutations - .map((it) => it.modified) - .filter((it) => inboxItemIdsToClaim.includes(it.id)) - .flatMap((it) => it.rewards); - inboxItemIdsToClaim.length = 0; - claimRewards(allRewards); - }, - strategy: flushStrategy.strategy, - }); - - const updateInbox = (options: { - from: InboxItem["status"][]; - to: InboxItem["status"]; - }): void => { - inboxCollection.forEach((it) => { - if (options.from.includes(it.status)) { - mutate({ id: it.id, status: options.to }); - } - }); - }; - const inboxSize = () => inboxQuery().length; return ( @@ -128,9 +59,7 @@ export function Inbox(): JSXElement {