Skip to content

Commit 936f7cb

Browse files
authored
fix(inbox): mark claimed rewards as claimed (@fehmer) (#7916)
Fix rewards can be claimed multiple times on the frontend
1 parent c9fa4de commit 936f7cb

4 files changed

Lines changed: 102 additions & 90 deletions

File tree

frontend/src/ts/collections/inbox.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { MonkeyMail } from "@monkeytype/schemas/users";
1+
import { AllRewards, MonkeyMail } from "@monkeytype/schemas/users";
22
import { queryCollectionOptions } from "@tanstack/query-db-collection";
33
import {
44
createCollection,
5+
createPacedMutations,
56
eq,
67
MutationFnParams,
78
not,
@@ -13,9 +14,17 @@ import { queryClient } from "../queries";
1314
import { baseKey } from "../queries/utils/keys";
1415
import { isAuthenticated } from "../states/core";
1516
import { flushDebounceStrategy } from "./utils/flushDebounceStrategy";
16-
import { showErrorNotification } from "../states/notifications";
17+
import {
18+
showErrorNotification,
19+
showSuccessNotification,
20+
} from "../states/notifications";
21+
import * as BadgeController from "../controllers/badge-controller";
22+
import { addBadge, addXp } from "../db";
1723

18-
export const flushStrategy = flushDebounceStrategy({ maxWait: 1000 * 60 * 5 });
24+
const flushStrategy = flushDebounceStrategy({ maxWait: 1000 * 60 * 5 });
25+
export function applyPendingInboxActions(): void {
26+
flushStrategy.flush();
27+
}
1928

2029
const queryKeys = {
2130
root: () => [...baseKey("inbox", { isUserSpecific: true })],
@@ -24,11 +33,10 @@ const queryKeys = {
2433
const [maxMailboxSize, setMaxMailboxSize] = createSignal(0);
2534

2635
export { maxMailboxSize };
27-
2836
export type InboxItem = Omit<MonkeyMail, "read"> & {
2937
status: "unclaimed" | "unread" | "read" | "deleted";
3038
};
31-
export const inboxCollection = createCollection(
39+
const inboxCollection = createCollection(
3240
queryCollectionOptions({
3341
staleTime: 1000 * 60 * 5,
3442
queryKey: queryKeys.root(),
@@ -58,7 +66,81 @@ export const inboxCollection = createCollection(
5866
}),
5967
);
6068

61-
export async function flushPendingChanges({
69+
export async function refetchInboxCollection(): Promise<void> {
70+
await inboxCollection.utils.refetch();
71+
}
72+
73+
const inboxItemIdsToClaim: string[] = [];
74+
export const mutateInboxItem = createPacedMutations<
75+
Pick<InboxItem, "id" | "status">,
76+
InboxItem
77+
>({
78+
onMutate: ({ id, status }) => {
79+
inboxCollection.update(id, (old) => {
80+
if (old.status === "unclaimed") {
81+
inboxItemIdsToClaim.push(old.id);
82+
}
83+
old.status = status;
84+
});
85+
},
86+
mutationFn: async (changes) => {
87+
await flushPendingChanges(changes);
88+
89+
const allRewards: AllRewards[] = changes.transaction.mutations
90+
.map((it) => it.modified)
91+
.filter((it) => inboxItemIdsToClaim.includes(it.id))
92+
.flatMap((it) => it.rewards);
93+
inboxItemIdsToClaim.length = 0;
94+
claimRewards(allRewards);
95+
},
96+
strategy: flushStrategy.strategy,
97+
});
98+
99+
function claimRewards(pendingRewards: AllRewards[]): void {
100+
if (pendingRewards.length === 0) return;
101+
102+
let totalXp = 0;
103+
const badgeNames: string[] = [];
104+
for (const reward of pendingRewards) {
105+
if (reward.type === "xp") {
106+
totalXp += reward.item;
107+
} else if (reward.type === "badge") {
108+
const badge = BadgeController.getById(reward.item.id);
109+
if (badge) {
110+
badgeNames.push(badge.name);
111+
addBadge(reward.item);
112+
}
113+
}
114+
}
115+
if (totalXp > 0) {
116+
addXp(totalXp);
117+
}
118+
119+
if (badgeNames.length > 0) {
120+
showSuccessNotification(
121+
`New badge${badgeNames.length > 1 ? "s" : ""} unlocked: ${badgeNames.join(", ")}`,
122+
{ durationMs: 5000, customTitle: "Reward", customIcon: "gift" },
123+
);
124+
}
125+
}
126+
127+
export function claimAllInboxItems(): void {
128+
inboxCollection.forEach((it) => {
129+
if (it.status === "unclaimed") {
130+
mutateInboxItem({ id: it.id, status: "read" });
131+
}
132+
});
133+
}
134+
135+
export function deleteAllInboxItems(): void {
136+
inboxCollection.forEach((it) => {
137+
if (it.status === "unread" || it.status === "read") {
138+
mutateInboxItem({ id: it.id, status: "deleted" });
139+
}
140+
});
141+
}
142+
143+
async function flushPendingChanges({
62144
transaction,
63145
}: MutationFnParams<InboxItem>): Promise<unknown> {
64146
const updatedStatus = Object.groupBy(
@@ -84,6 +166,9 @@ export async function flushPendingChanges({
84166
updatedStatus.deleted?.forEach((deleted) =>
85167
inboxCollection.utils.writeDelete(deleted.id),
86168
);
169+
updatedStatus.read?.forEach((read) => {
170+
inboxCollection.utils.writeUpdate(read);
171+
});
87172
});
88173

89174
return { refetch: false };

frontend/src/ts/components/modals/DevOptionsModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { envConfig } from "virtual:env-config";
33

44
import Ape from "../../ape";
55
import { signIn } from "../../auth";
6-
import { inboxCollection } from "../../collections/inbox";
6+
import { refetchInboxCollection } from "../../collections/inbox";
77
import { addXp } from "../../db";
88
import { toggleCaretDebug } from "../../elements/caret";
99
import { getInputElement } from "../../input/input-element";
@@ -177,7 +177,7 @@ export function DevOptionsModal(): JSXElement {
177177
return;
178178
}
179179
showSuccessNotification("Debug inbox item added");
180-
void inboxCollection.utils.refetch();
180+
void refetchInboxCollection();
181181
});
182182
};
183183

frontend/src/ts/components/popups/alerts/AlertsPopup.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { JSXElement } from "solid-js";
22

3-
import { flushStrategy } from "../../../collections/inbox";
3+
import { applyPendingInboxActions } from "../../../collections/inbox";
44
import { hideModalAndClearChain } from "../../../states/modals";
55
import { AnimatedModal } from "../../common/AnimatedModal";
66
import { Button } from "../../common/Button";
@@ -30,7 +30,7 @@ export function AlertsPopup(): JSXElement {
3030
onBackdropClick={() => hideModalAndClearChain("Alerts")}
3131
afterHide={() => {
3232
setTimeout(() => {
33-
flushStrategy.flush();
33+
applyPendingInboxActions();
3434
}, 125);
3535
}}
3636
>

frontend/src/ts/components/popups/alerts/Inbox.tsx

Lines changed: 7 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1-
import { AllRewards } from "@monkeytype/schemas/users";
2-
import { createPacedMutations } from "@tanstack/solid-db";
31
import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
42
import { createEffect, For, JSXElement, Show } from "solid-js";
53

64
import {
7-
flushPendingChanges,
8-
flushStrategy,
9-
inboxCollection,
5+
claimAllInboxItems,
6+
deleteAllInboxItems,
107
InboxItem,
118
maxMailboxSize,
9+
mutateInboxItem,
1210
useInboxQuery,
1311
} from "../../../collections/inbox";
14-
import * as BadgeController from "../../../controllers/badge-controller";
15-
import { addBadge, addXp, updateInboxUnreadSize } from "../../../db";
12+
import { updateInboxUnreadSize } from "../../../db";
1613
import { getModalVisibility } from "../../../states/modals";
17-
import { showSuccessNotification } from "../../../states/notifications";
1814
import { cn } from "../../../utils/cn";
1915
import AsyncContent from "../../common/AsyncContent";
2016
import { Button } from "../../common/Button";
@@ -23,40 +19,11 @@ import { H3 } from "../../common/Headers";
2319
import { LoadingCircle } from "../../common/LoadingCircle";
2420
import { AlertsSection } from "./AlertsSection";
2521

26-
const inboxItemIdsToClaim: string[] = [];
2722
export function Inbox(): JSXElement {
2823
const inboxQuery = useInboxQuery(
2924
() => getModalVisibility("Alerts")?.visible ?? false,
3025
);
3126

32-
const claimRewards = (pendingRewards: AllRewards[]) => {
33-
if (pendingRewards.length === 0) return;
34-
35-
let totalXp = 0;
36-
const badgeNames: string[] = [];
37-
for (const reward of pendingRewards) {
38-
if (reward.type === "xp") {
39-
totalXp += reward.item;
40-
} else if (reward.type === "badge") {
41-
const badge = BadgeController.getById(reward.item.id);
42-
if (badge) {
43-
badgeNames.push(badge.name);
44-
addBadge(reward.item);
45-
}
46-
}
47-
}
48-
if (totalXp > 0) {
49-
addXp(totalXp);
50-
}
51-
52-
if (badgeNames.length > 0) {
53-
showSuccessNotification(
54-
`New badge${badgeNames.length > 1 ? "s" : ""} unlocked: ${badgeNames.join(", ")}`,
55-
{ durationMs: 5000, customTitle: "Reward", customIcon: "gift" },
56-
);
57-
}
58-
};
59-
6027
createEffect(() => {
6128
const items = inboxQuery();
6229
const count = items.filter(
@@ -65,42 +32,6 @@ export function Inbox(): JSXElement {
6532
updateInboxUnreadSize(count);
6633
});
6734

68-
const mutate = createPacedMutations<
69-
Pick<InboxItem, "id" | "status">,
70-
InboxItem
71-
>({
72-
onMutate: ({ id, status }) => {
73-
inboxCollection.update(id, (old) => {
74-
if (old.status === "unclaimed") {
75-
inboxItemIdsToClaim.push(old.id);
76-
}
77-
old.status = status;
78-
});
79-
},
80-
mutationFn: async (changes) => {
81-
await flushPendingChanges(changes);
82-
83-
const allRewards: AllRewards[] = changes.transaction.mutations
84-
.map((it) => it.modified)
85-
.filter((it) => inboxItemIdsToClaim.includes(it.id))
86-
.flatMap((it) => it.rewards);
87-
inboxItemIdsToClaim.length = 0;
88-
claimRewards(allRewards);
89-
},
90-
strategy: flushStrategy.strategy,
91-
});
92-
93-
const updateInbox = (options: {
94-
from: InboxItem["status"][];
95-
to: InboxItem["status"];
96-
}): void => {
97-
inboxCollection.forEach((it) => {
98-
if (options.from.includes(it.status)) {
99-
mutate({ id: it.id, status: options.to });
100-
}
101-
});
102-
};
103-
10435
const inboxSize = () => inboxQuery().length;
10536

10637
return (
@@ -128,9 +59,7 @@ export function Inbox(): JSXElement {
12859
<Button
12960
fa={{ icon: "fa-gift", fixedWidth: true }}
13061
text="Claim all"
131-
onClick={() =>
132-
updateInbox({ from: ["unclaimed"], to: "read" })
133-
}
62+
onClick={() => claimAllInboxItems()}
13463
/>
13564
</Show>
13665
<Show
@@ -144,17 +73,15 @@ export function Inbox(): JSXElement {
14473
<Button
14574
fa={{ icon: "fa-trash", fixedWidth: true }}
14675
text="Delete all"
147-
onClick={() =>
148-
updateInbox({ from: ["read", "unread"], to: "deleted" })
149-
}
76+
onClick={() => deleteAllInboxItems()}
15077
/>
15178
</Show>
15279

15380
<For
15481
each={inboxQueryData()}
15582
fallback={<div class="place-self-center">Nothing to show</div>}
15683
>
157-
{(entry) => <Entry entry={entry} mutate={mutate} />}
84+
{(entry) => <Entry entry={entry} mutate={mutateInboxItem} />}
15885
</For>
15986
</>
16087
)}

0 commit comments

Comments
 (0)