From 262b4483c56eb04fe0d9ee1557ec9a36498b2f18 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 12 Jun 2026 13:32:46 +0200 Subject: [PATCH 1/4] chore: fix story files --- .../stories/AnimatedModal.stories.tsx | 91 ++++----- .../storybook/stories/AnimeShow.stories.tsx | 35 +--- .../storybook/stories/AutoShrink.stories.tsx | 27 +-- frontend/storybook/stories/Bar.stories.tsx | 39 +--- frontend/storybook/stories/Button.stories.tsx | 40 +--- .../storybook/stories/ChartJs.stories.tsx | 193 +++++++++--------- .../stories/DiscordAvatar.stories.tsx | 43 ++-- frontend/storybook/stories/Fa.stories.tsx | 44 +--- .../stories/FieldIndicator.stories.tsx | 70 ++++--- frontend/storybook/stories/H2.stories.tsx | 15 +- frontend/storybook/stories/H3.stories.tsx | 18 +- .../storybook/stories/InputField.stories.tsx | 75 +++---- .../stories/NotificationBubble.stories.tsx | 27 +-- frontend/storybook/stories/User.stories.tsx | 149 +++++++------- .../storybook/stories/UserBadge.stories.tsx | 37 ++-- .../storybook/stories/UserFlags.stories.tsx | 42 +--- .../storybook/stories/UserProfile.stories.tsx | 117 ++++++----- 17 files changed, 446 insertions(+), 616 deletions(-) diff --git a/frontend/storybook/stories/AnimatedModal.stories.tsx b/frontend/storybook/stories/AnimatedModal.stories.tsx index d21fafd9b624..4163ffb94db0 100644 --- a/frontend/storybook/stories/AnimatedModal.stories.tsx +++ b/frontend/storybook/stories/AnimatedModal.stories.tsx @@ -1,3 +1,5 @@ +import { Component } from "solid-js"; + import preview from "#.storybook/preview"; import { AnimatedModal } from "../../src/ts/components/common/AnimatedModal"; @@ -23,71 +25,52 @@ function ModalTrigger(props: { modalId: ModalId; label: string }) { const meta = preview.meta({ title: "Common/AnimatedModal", - component: AnimatedModal, + component: AnimatedModal as Component, parameters: { layout: "fullscreen", }, tags: ["autodocs"], - argTypes: { - mode: { control: "select", options: ["modal", "dialog"] }, - animationMode: { - control: "select", - options: ["none", "both", "modalOnly"], - }, - title: { control: "text" }, - modalClass: { control: "text" }, - wrapperClass: { control: "text" }, - }, - decorators: [ - (Story, context) => { - // oxlint-disable-next-line typescript/no-unsafe-member-access -- storybook decorator context is untyped - const modalId = context.args.id as ModalId; - // oxlint-disable-next-line typescript/no-unsafe-member-access -- storybook decorator context is untyped - const title = (context.args.title as string) ?? "Modal"; - return ( -
- - -
- ); - }, - ], }); export const Default = meta.story({ - args: { - id: "Contact", - title: "Example Modal", - children: ( -
-

This is modal content.

-
- ), - }, + render: () => ( + <> + + +
+

This is modal content.

+
+
+ + ), }); export const NoAnimation = meta.story({ - args: { - id: "Support", - title: "No Animation Modal", - animationMode: "none", - children: ( -
-

This modal has no animation.

-
- ), - }, + render: () => ( + <> + + +
+

This modal has no animation.

+
+
+ + ), }); export const DialogMode = meta.story({ - args: { - id: "DevOptions", - mode: "dialog", - title: "Dialog Mode", - children: ( -
-

This uses dialog mode instead of modal.

-
- ), - }, + render: () => ( + <> + + +
+

This uses dialog mode instead of modal.

+
+
+ + ), }); diff --git a/frontend/storybook/stories/AnimeShow.stories.tsx b/frontend/storybook/stories/AnimeShow.stories.tsx index f9ba885c30bc..1688a9a7db5f 100644 --- a/frontend/storybook/stories/AnimeShow.stories.tsx +++ b/frontend/storybook/stories/AnimeShow.stories.tsx @@ -1,49 +1,36 @@ -import preview from "#.storybook/preview"; import { Component, createSignal } from "solid-js"; +import preview from "#.storybook/preview"; + import { AnimeShow } from "../../src/ts/components/common/anime/AnimeShow"; const meta = preview.meta({ title: "Common/Anime/AnimeShow", - component: AnimeShow as Component<{ - when: boolean; - slide?: true; - duration?: number; - class?: string; - }>, + component: AnimeShow as Component, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: { - when: { control: "boolean" }, - slide: { control: "boolean" }, - duration: { control: "number" }, - }, }); export const FadeToggle = meta.story({ - args: { - when: true, - children: ( + render: () => ( +
This content fades in and out
- ), - }, +
+ ), }); export const SlideToggle = meta.story({ - args: { - when: true, - slide: true, - duration: 250, - children: ( + render: () => ( +
This content slides in and out
- ), - }, +
+ ), }); export const InteractiveDemo = meta.story({ diff --git a/frontend/storybook/stories/AutoShrink.stories.tsx b/frontend/storybook/stories/AutoShrink.stories.tsx index d34d7f4f03df..538d3ebd34a5 100644 --- a/frontend/storybook/stories/AutoShrink.stories.tsx +++ b/frontend/storybook/stories/AutoShrink.stories.tsx @@ -1,18 +1,16 @@ +import { Component } from "solid-js"; + import preview from "#.storybook/preview"; import { AutoShrink } from "../../src/ts/components/common/AutoShrink"; const meta = preview.meta({ title: "Common/AutoShrink", - component: AutoShrink, + component: AutoShrink as Component, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: { - upperLimitRem: { control: "number" }, - class: { control: "text" }, - }, decorators: [ (Story) => (
@@ -70,22 +68,17 @@ const meta = preview.meta({ }); export const Default = meta.story({ - args: { - upperLimitRem: 2, - children: "Short", - }, + render: () => Short, }); export const LongText = meta.story({ - args: { - upperLimitRem: 2, - children: "This is a much longer piece of text that should shrink to fit", - }, + render: () => ( + + This is a much longer piece of text that should shrink to fit + + ), }); export const LargeUpperLimit = meta.story({ - args: { - upperLimitRem: 4, - children: "Big text", - }, + render: () => Big text, }); diff --git a/frontend/storybook/stories/Bar.stories.tsx b/frontend/storybook/stories/Bar.stories.tsx index e220e26a6179..47ea5bf8c54b 100644 --- a/frontend/storybook/stories/Bar.stories.tsx +++ b/frontend/storybook/stories/Bar.stories.tsx @@ -1,53 +1,32 @@ +import { Component } from "solid-js"; + import preview from "#.storybook/preview"; import { Bar } from "../../src/ts/components/common/Bar"; const meta = preview.meta({ title: "Common/Bar", - component: Bar, + component: Bar as Component, parameters: { layout: "padded", }, tags: ["autodocs"], - argTypes: { - percent: { control: { type: "range", min: 0, max: 100 } }, - fill: { control: "select", options: ["main", "text"] }, - bg: { control: "select", options: ["bg", "sub-alt"] }, - showPercentageOnHover: { control: "boolean" }, - animationDuration: { control: "number" }, - }, }); export const Default = meta.story({ - args: { - percent: 50, - fill: "main", - bg: "sub-alt", - }, + render: () => , }); export const Full = meta.story({ - args: { - percent: 100, - fill: "main", - bg: "sub-alt", - }, + render: () => , }); export const HalfWithHover = meta.story({ - args: { - percent: 50, - fill: "main", - bg: "sub-alt", - - showPercentageOnHover: true, - }, + render: () => ( + + ), }); export const TextFill = meta.story({ - args: { - percent: 75, - fill: "text", - bg: "sub-alt", - }, + render: () => , }); diff --git a/frontend/storybook/stories/Button.stories.tsx b/frontend/storybook/stories/Button.stories.tsx index 9d7f4c13a882..2e6e77d4ad1c 100644 --- a/frontend/storybook/stories/Button.stories.tsx +++ b/frontend/storybook/stories/Button.stories.tsx @@ -1,3 +1,4 @@ +import { Component } from "solid-js"; import { fn } from "storybook/test"; import preview from "#.storybook/preview"; @@ -6,50 +7,15 @@ import { Button } from "../../src/ts/components/common/Button"; const meta = preview.meta({ title: "Common/Button", - component: Button, + component: Button as Component, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: { - active: { - control: "boolean", - }, - disabled: { - control: "boolean", - }, - text: { - control: "text", - }, - fa: { - control: "object", - }, - balloon: { - control: "object", - }, - class: { - control: "text", - }, - "router-link": { - control: "boolean", - }, - href: { - control: "text", - }, - sameTarget: { - control: "boolean", - }, - }, - args: { - onClick: fn(), - }, }); export const Default = meta.story({ - args: { - text: "Button", - type: "button", - }, + render: () => -
- - - - - - - - - - - - - - - - - - - - -
namefriends forlevel - tests - time typingstreaktime 15 pbtime 60 pb
- - - diff --git a/frontend/src/index.html b/frontend/src/index.html index da677825157e..323ead604432 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -58,7 +58,11 @@ - + + + diff --git a/frontend/src/styles/account-settings.scss b/frontend/src/styles/account-settings.scss index 6ecfef6d6a56..ca4816e592e3 100644 --- a/frontend/src/styles/account-settings.scss +++ b/frontend/src/styles/account-settings.scss @@ -116,8 +116,7 @@ } } } - &[data-tab="apeKeys"], - &[data-tab="blockedUsers"] { + &[data-tab="apeKeys"] { table { width: 100%; border-spacing: 0; @@ -186,15 +185,6 @@ text-align: center; } } - &[data-tab="blockedUsers"] { - tr td:first-child a { - text-decoration: none; - color: var(--text-color); - } - tr td:last-child { - text-align: right; - } - } } } // .right { diff --git a/frontend/src/styles/friends.scss b/frontend/src/styles/friends.scss deleted file mode 100644 index ee36fd6eb5b1..000000000000 --- a/frontend/src/styles/friends.scss +++ /dev/null @@ -1,143 +0,0 @@ -.pageFriends { - .bigTitle { - color: var(--sub-color); - font-size: 2rem; - } - - .friendAdd { - padding-left: 1em; - padding-right: 1em; - } - - .titleAndButton { - display: grid; - grid-template-columns: 1fr auto; - margin-bottom: 1rem; - align-items: center; - } - - .nodata { - color: var(--sub-color); - padding: 5rem 0; - text-align: center; - } - - .pendingRequests, - .friends { - margin-bottom: 4rem; - table { - border-spacing: 0; - border-collapse: collapse; - color: var(--text-color); - --padding: 1em 1.5rem; - - .small { - font-size: 0.75em; - } - - thead { - color: var(--sub-color); - font-size: 0.75rem; - } - - tr.me { - color: var(--main-color); - } - - tbody tr:nth-child(odd) td { - background: var(--sub-alt-color); - } - - tbody td:first-child { - border-radius: var(--roundness) 0 0 var(--roundness); - } - tbody td:last-child { - border-radius: 0 var(--roundness) var(--roundness) 0; - } - - td { - padding: var(--padding); - appearance: unset; - - &:last-child { - text-align: right; - } - - //don't wrap friendsfor rand streak into multiple lines - &:nth-child(2), - &:nth-child(6) { - white-space: nowrap; - } - } - - .sub { - opacity: 0.5; - } - - // .actions button { - // opacity: 0; - // transition: opacity 0.125s; - // } - - // tr:hover button { - // opacity: 1; - // } - } - } - .pendingRequests { - table tr { - td:first-child a { - text-decoration: none; - color: var(--text-color); - } - } - } - - .friends { - .avatarNameBadge { - display: grid; - grid-template-columns: 1.25em max-content auto; - gap: 0.5em; - place-items: center left; - .avatarPlaceholder { - width: 1.25em; - height: 1.25em; - font-size: 1.25em; - // background: var(--sub-color); - color: var(--sub-color); - // display: grid; - // place-content: center center; - border-radius: 100%; - } - .entryName { - text-decoration: none; - color: inherit; - cursor: pointer; - } - .avatarPlaceholder, - .avatar { - grid-row: 1/2; - grid-column: 1/2; - .userIcon { - color: var(--sub-color); - } - } - .badge { - font-size: 0.6em; - } - .flagsAndBadge { - display: flex; - gap: 0.5em; - color: var(--sub-color); - place-items: center; - } - } - } - .loading { - display: grid; - place-items: center; - font-size: 3em; - color: var(--sub-color); - padding: 1em; - } -} diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss index 7302a48514aa..9bba1adc898c 100644 --- a/frontend/src/styles/index.scss +++ b/frontend/src/styles/index.scss @@ -18,7 +18,7 @@ @layer custom-styles { @import "buttons", "404", "ads", "test-activity", "animations", "caret", "commandline", "core", "fonts", "inputs", "keymap", "monkey", "popups", - "scroll", "account-settings", "test", "loading", "friends", "media-queries"; + "scroll", "account-settings", "test", "loading", "media-queries"; .chartCanvas { width: 100% !important; diff --git a/frontend/src/styles/media-queries-blue.scss b/frontend/src/styles/media-queries-blue.scss index 5e0c7a251003..89a0dc890631 100644 --- a/frontend/src/styles/media-queries-blue.scss +++ b/frontend/src/styles/media-queries-blue.scss @@ -35,7 +35,6 @@ } } } - .pageAccountSettings { .main { grid-template-columns: 1fr; @@ -51,13 +50,6 @@ } } } - - .pageFriends { - .content .friends table, - .content .pendingRequests table { - font-size: 0.75rem; - } - } } @media (pointer: coarse) and (max-width: 778px) { #restartTestButton { diff --git a/frontend/src/styles/media-queries-brown.scss b/frontend/src/styles/media-queries-brown.scss index 1e6e97077dba..b65e0092d5b0 100644 --- a/frontend/src/styles/media-queries-brown.scss +++ b/frontend/src/styles/media-queries-brown.scss @@ -38,25 +38,4 @@ .testActivity { display: none; } - .pageFriends { - .content .friends table { - td:nth-child(3) { - display: none; - } - } - .content .pendingRequests table { - td:nth-child(2) { - display: none; - } - } - } - - .pageAccountSettings [data-tab="blockedUsers"] { - table { - font-size: 0.75rem; - td:nth-child(2) { - display: none; - } - } - } } diff --git a/frontend/src/styles/media-queries-green.scss b/frontend/src/styles/media-queries-green.scss index cbaf14a739d0..559d955b3833 100644 --- a/frontend/src/styles/media-queries-green.scss +++ b/frontend/src/styles/media-queries-green.scss @@ -49,14 +49,4 @@ // border-radius: 0.2em; // } } - - .pageFriends { - .content .friends table { - td:nth-child(4), - td:nth-child(7), - td:nth-child(8) { - display: none; - } - } - } } diff --git a/frontend/src/styles/media-queries-purple.scss b/frontend/src/styles/media-queries-purple.scss index ef35fc4107be..66b689896cee 100644 --- a/frontend/src/styles/media-queries-purple.scss +++ b/frontend/src/styles/media-queries-purple.scss @@ -92,19 +92,4 @@ .modalWrapper .modal .inputs.withLabel { grid-template-columns: 1fr; } - - .pageFriends { - .content .friends table { - td:nth-child(5), - td:nth-child(6) { - display: none; - } - } - } - - .pageAccountSettings [data-tab="blockedUsers"] { - table { - font-size: 0.75rem; - } - } } diff --git a/frontend/src/ts/collections/connections.ts b/frontend/src/ts/collections/connections.ts new file mode 100644 index 000000000000..e33222a79a5d --- /dev/null +++ b/frontend/src/ts/collections/connections.ts @@ -0,0 +1,253 @@ +import { + and, + createCollection, + eq, + useLiveQuery, + not, + createOptimisticAction, +} from "@tanstack/solid-db"; +import { baseKey } from "../queries/utils/keys"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { queryClient } from "../queries"; +import Ape from "../ape"; +import { applyIdWorkaround, tempId } from "./utils/misc"; +import { getUserId, isAuthenticated } from "../states/core"; + +import { + configurationPromise, + get as getServerConfiguration, +} from "../ape/server-configuration"; +import { getSnapshot } from "../states/snapshot"; +import { Connection } from "@monkeytype/schemas/connections"; +import { showNoticeNotification } from "../states/notifications"; +import { invalidateFriendsList } from "../queries/friends"; + +const queryKeys = { + root: () => [...baseKey("connections", { isUserSpecific: true })], +}; + +const connectionsCollection = createCollection( + queryCollectionOptions({ + staleTime: 5 * 60 * 1000, + queryKey: queryKeys.root(), + queryClient, + enabled: isAuthenticated, + getKey: (it) => it._id, + queryFn: async () => { + await configurationPromise; + if (!getServerConfiguration()?.connections.enabled) return []; + const response = await Ape.connections.get(); + if (response.status !== 200) { + throw new Error(`Error fetching connections:${response.body.message}`); + } + + return response.body.data.map(applyIdWorkaround); + }, + }), +); + +const connectionsQuery = useLiveQuery((q) => + isAuthenticated() + ? q.from({ connections: connectionsCollection }) + : undefined, +); + +// oxlint-disable-next-line typescript/explicit-function-return-type +export function usePendingConnectionsQuery() { + return useLiveQuery((q) => + isAuthenticated() + ? q + .from({ connections: connectionsCollection }) + .where(({ connections }) => + and( + eq(connections.status, "pending"), + not(eq(connections.initiatorUid, getUserId())), + ), + ) + .orderBy(({ connections }) => connections.lastModified, "desc") + : undefined, + ); +} +// oxlint-disable-next-line typescript/explicit-function-return-type +export function useBlockedConnectionsQuery() { + return useLiveQuery((q) => + isAuthenticated() + ? q + .from({ connections: connectionsCollection }) + .where(({ connections }) => + and( + eq(connections.status, "blocked"), + not(eq(connections.initiatorUid, getUserId())), + ), + ) + .orderBy(({ connections }) => connections.lastModified, "desc") + : undefined, + ); +} + +type ActionType = { + acceptConnection: { + id: string; + }; + rejectConnection: { + id: string; + }; + blockConnection: { + id: string; + }; + addConnection: { + receiverName: string; + receiverUid?: string; + }; +}; + +const actions = { + acceptConnection: createOptimisticAction({ + onMutate: ({ id }) => { + connectionsCollection.update(id, (old) => (old.status = "accepted")); + }, + mutationFn: async ({ id }) => { + const response = await Ape.connections.update({ + params: { id }, + body: { status: "accepted" }, + }); + if (response.status !== 200) { + throw new Error( + `Failed to accept connection: ${response.body.message}`, + ); + } + connectionsCollection.utils.writeUpdate({ + _id: id, + status: "accepted", + }); + + await invalidateFriendsList(); + }, + }), + blockConnection: createOptimisticAction({ + onMutate: ({ id }) => { + connectionsCollection.update(id, (old) => (old.status = "blocked")); + }, + mutationFn: async ({ id }) => { + const response = await Ape.connections.update({ + params: { id }, + body: { status: "blocked" }, + }); + if (response.status !== 200) { + throw new Error(`Failed to block connection: ${response.body.message}`); + } + connectionsCollection.utils.writeUpdate({ + _id: id, + status: "blocked", + }); + }, + }), + rejectConnection: createOptimisticAction({ + onMutate: ({ id }) => { + connectionsCollection.delete(id); + }, + mutationFn: async ({ id }) => { + const response = await Ape.connections.delete({ params: { id } }); + if (response.status !== 200) { + throw new Error( + `Failed to reject connection: ${response.body.message}`, + ); + } + connectionsCollection.utils.writeDelete(id); + + await invalidateFriendsList(); + }, + }), + addConnection: createOptimisticAction({ + onMutate: ({ receiverName, receiverUid }) => { + connectionsCollection.insert({ + _id: tempId(), + status: "pending", + receiverName, + receiverUid: receiverUid ?? tempId(), + initiatorName: getSnapshot()?.name ?? "", + initiatorUid: getSnapshot()?.uid ?? "", + lastModified: Date.now(), + }); + }, + mutationFn: async ({ receiverName }) => { + const response = await Ape.connections.create({ body: { receiverName } }); + + if (response.status === 200) { + connectionsCollection.utils.writeInsert(response.body.data); + showNoticeNotification(`Request sent to ${receiverName}`); + } else { + throw new Error(`Failed to add connection: ${response.body.message}`); + } + }, + }), +}; + +// -- Public API --- +export function isConnectionsReady(): boolean { + return connectionsCollection.isReady(); +} + +export async function waitForConnectionsReady(): Promise { + await connectionsCollection.stateWhenReady(); +} + +export async function acceptConnection( + params: ActionType["acceptConnection"], +): Promise { + const transaction = actions.acceptConnection(params); + await transaction.isPersisted.promise; +} + +export async function rejectConnection( + params: ActionType["rejectConnection"], +): Promise { + const transaction = actions.rejectConnection(params); + await transaction.isPersisted.promise; +} + +export async function blockConnection( + params: ActionType["blockConnection"], +): Promise { + const transaction = actions.blockConnection(params); + await transaction.isPersisted.promise; +} + +export async function addConnection( + params: ActionType["addConnection"], +): Promise { + const transaction = actions.addConnection(params); + await transaction.isPersisted.promise; +} + +export function hasConnection( + uid: string | undefined, + status?: Connection["status"], +): boolean { + if (uid === undefined || uid === getUserId()) return false; + return ( + connectionsQuery().find( + (it) => + (status === undefined || it.status === status) && + (it.receiverUid === uid || it.initiatorUid === uid), + ) !== undefined + ); +} + +export function findConnectionToUser( + userName: string | undefined, + status?: Connection["status"], +): Connection | undefined { + if (userName === undefined) return undefined; + return connectionsQuery().find( + (it) => + (status === undefined || it.status === status) && + (it.receiverName === userName || it.initiatorName === userName), + ); +} + +export async function invalidateConnections(): Promise { + await queryClient.invalidateQueries({ + queryKey: queryKeys.root(), + }); +} diff --git a/frontend/src/ts/components/common/User.tsx b/frontend/src/ts/components/common/User.tsx index ebb53d6c25af..18aebfd045c9 100644 --- a/frontend/src/ts/components/common/User.tsx +++ b/frontend/src/ts/components/common/User.tsx @@ -7,6 +7,7 @@ import { SupportsFlags, UserFlagOptions, } from "../../controllers/user-flag-controller"; +import { BreakpointKey } from "../../states/breakpoints"; import { cn } from "../../utils/cn"; import { Anime } from "./anime"; import { AnimePresence } from "./anime/AnimePresence"; @@ -33,6 +34,7 @@ type Props = { showSpinner?: boolean; showNotificationBubble?: boolean; fontClass?: "text-em-xs" | "text-em-sm" | "text-em-md" | "text-em-lg"; + hideBadgeTextOnWidth?: BreakpointKey; } & UserFlagOptions; export function User(props: Props): JSXElement { @@ -158,7 +160,10 @@ export function User(props: Props): JSXElement { - + ; - hideTextOnSmallScreens?: boolean; + hideTextOnWidth?: BreakpointKey | false; hideDescription?: boolean; }): JSXElement { const badge = (): UserBadgeType | undefined => props.id !== undefined ? badges[props.id] : undefined; + + const hideClasses: Record = { + xxs: "hidden xs:inline", + xs: "hidden sm:inline", + sm: "hidden md:inline", + md: "hidden lg:inline", + lg: "hidden xl:inline", + xl: "hidden 2xl:inline", + xxl: "hidden 3xl:inline", + }; + return ( {badge()?.name} diff --git a/frontend/src/ts/components/layout/header/Nav.tsx b/frontend/src/ts/components/layout/header/Nav.tsx index a020e2abe0a2..7cf454b15b34 100644 --- a/frontend/src/ts/components/layout/header/Nav.tsx +++ b/frontend/src/ts/components/layout/header/Nav.tsx @@ -7,6 +7,7 @@ import { Show, } from "solid-js"; +import { usePendingConnectionsQuery } from "../../../collections/connections"; import { restartTestEvent } from "../../../events/test"; import { createEffectOn } from "../../../hooks/effects"; import { useRefWithUtils } from "../../../hooks/useRefWithUtils"; @@ -39,6 +40,8 @@ export function Nav(): JSXElement { const isCoarse = () => window.matchMedia("(pointer: coarse)").matches; const [accountMenuRef, accountMenuEl] = useRefWithUtils(); + const pendingConnections = usePendingConnectionsQuery(); + const handleClickOutside = (e: MouseEvent) => { const el = accountMenuEl(); if (getAccountMenuOpen() && el && !el.native.contains(e.target as Node)) { @@ -62,17 +65,7 @@ export function Nav(): JSXElement { }); const showFriendsNotificationBubble = createMemo((): boolean => { - const friends = getSnapshot()?.connections; - - if (friends !== undefined) { - const pendingFriendRequests = Object.values(friends).filter( - (it) => it === "incoming", - ).length; - if (pendingFriendRequests > 0) { - return true; - } - } - return false; + return pendingConnections().length > 0; }); const showAlertsNotificationBubble = createMemo((): boolean => { diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index be7e5ed6e58b..495efdf591ff 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -12,8 +12,10 @@ import { Header } from "./layout/header/Header"; import { Overlays } from "./layout/overlays/Overlays"; import { Modals } from "./modals/Modals"; import { AboutPage } from "./pages/AboutPage"; +import { BlockedUsers } from "./pages/account-settings/BlockedUsers"; import { AccountPage } from "./pages/account/AccountPage"; import { MyProfile } from "./pages/account/MyProfile"; +import { FriendsPage } from "./pages/connections/FriendsPage"; import { LeaderboardPage } from "./pages/leaderboard/LeaderboardPage"; import { LoginPage } from "./pages/login/LoginPage"; import { ProfilePage } from "./pages/profile/ProfilePage"; @@ -42,6 +44,8 @@ const components: Record JSXElement> = { testconfig: () => , commandlinehotkey: () => , testmodesnotice: () => , + friendspage: () => , + blockedusers: () => , }; function mountToMountpoint(name: string, component: () => JSXElement): void { diff --git a/frontend/src/ts/components/pages/account-settings/BlockedUsers.tsx b/frontend/src/ts/components/pages/account-settings/BlockedUsers.tsx new file mode 100644 index 000000000000..8d56f3eff67d --- /dev/null +++ b/frontend/src/ts/components/pages/account-settings/BlockedUsers.tsx @@ -0,0 +1,89 @@ +import { Connection } from "@monkeytype/schemas/connections"; +import { createColumnHelper } from "@tanstack/solid-table"; +import { format } from "date-fns/format"; +import { createMemo, Show } from "solid-js"; + +import { + rejectConnection, + useBlockedConnectionsQuery, +} from "../../../collections/connections"; +import { showSimpleModal } from "../../../states/simple-modal"; +import AsyncContent from "../../common/AsyncContent"; +import { Button } from "../../common/Button"; +import { H3 } from "../../common/Headers"; +import { User } from "../../common/User"; +import { DataTable, DataTableColumnDef } from "../../ui/table/DataTable"; + +export function BlockedUsers() { + const query = useBlockedConnectionsQuery(); + const columns = createMemo(getColumns); + + return ( +
+

+

Blocked users cannot send you friend requests.

+ + + {({ queryData }) => ( + 0} + fallback={

You have not blocked any users.

} + > + +
+ )} +
+

+ ); +} + +function getColumns(): DataTableColumnDef[] { + const defineColumn = createColumnHelper().accessor; + const cols = [ + defineColumn("initiatorName", { + header: "name", + cell: (info) => ( + + ), + }), + defineColumn("lastModified", { + header: "blocked on", + cell: ({ getValue }) => format(getValue(), "dd MMM yyyy HH:mm"), + }), + defineColumn("_id", { + header: "", + cell: (info) => ( + - - - `, - ); - table?.appendHtml(content.join()); -} - -element.onChild("click", "table button.delete", async (e) => { - const row = (e.childTarget as HTMLElement).closest("tr") as HTMLElement; - const id = row?.dataset["id"]; - - if (id === undefined) { - throw new Error("Cannot find id of target."); - } - - row.querySelectorAll("button").forEach((button) => (button.disabled = true)); - - const response = await Ape.connections.delete({ params: { id } }); - if (response.status !== 200) { - showErrorNotification(`Cannot unblock user: ${response.body.message}`); - } else { - blockedUsers = blockedUsers.filter((it) => it._id !== id); - refreshList(); - - const snapshot = DB.getSnapshot(); - if (snapshot) { - const uid = row.dataset["uid"]; - if (uid === undefined) { - throw new Error("Cannot find uid of target."); - } - - // oxlint-disable-next-line no-dynamic-delete, no-unsafe-member-access - delete snapshot.connections[uid]; - DB.setSnapshot(snapshot); - } - } -}); diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index acf963bb1f9d..6452541bc1f4 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -8,7 +8,6 @@ import Ape from "../ape"; import * as StreakHourOffsetModal from "../modals/streak-hour-offset"; import { showLoaderBar } from "../states/loader-bar"; import * as ApeKeyTable from "../elements/account-settings/ape-key-table"; -import * as BlockedUserTable from "../elements/account-settings/blocked-user-table"; import { showErrorNotification } from "../states/notifications"; import { z } from "zod"; import { authEvent } from "../events/auth"; @@ -134,7 +133,6 @@ function updateTabs(): void { pageElement.qsa(".tab")?.removeClass("active"); pageElement.qs(`.tab[data-tab="${state.tab}"]`)?.addClass("active"); if (state.tab === "apeKeys") void ApeKeyTable.update(updateUI); - if (state.tab === "blockedUsers") void BlockedUserTable.update(); }, ); pageElement.qsa("button")?.removeClass("active"); diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts deleted file mode 100644 index 3edf4962187e..000000000000 --- a/frontend/src/ts/pages/friends.ts +++ /dev/null @@ -1,544 +0,0 @@ -import Page from "./page"; -import * as Skeleton from "../utils/skeleton"; -import Ape from "../ape"; -import { - intervalToDuration, - format as dateFormat, - formatDuration, - formatDistanceToNow, - format, -} from "date-fns"; -import { - showNoticeNotification, - showErrorNotification, - showSuccessNotification, -} from "../states/notifications"; -import { isSafeNumber } from "@monkeytype/util/numbers"; -import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller"; -import { formatXp, getXpDetails } from "../utils/levels"; -import { secondsToString } from "../utils/date-and-time"; -import { PersonalBest } from "@monkeytype/schemas/shared"; -import Format from "../singletons/format"; -import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; -import { SortedTable, SortSchema } from "../utils/sorted-table"; -import { getAvatarElement } from "../utils/discord-avatar"; -import { formatTypingStatsRatio } from "../utils/misc"; -import { getLanguageDisplayString } from "../utils/strings"; -import * as DB from "../db"; -import { addFriend, getReceiverUid } from "../db"; -import { getAuthenticatedUser } from "../firebase"; -import * as ServerConfiguration from "../ape/server-configuration"; -import { authEvent } from "../events/auth"; -import { Connection } from "@monkeytype/schemas/connections"; -import { UserNameWithoutFilterSchema, Friend } from "@monkeytype/schemas/users"; - -import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; -import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; -import { remoteValidation } from "../utils/remote-validation"; -import { qs, qsr, onDOMReady } from "../utils/dom"; -import { showSimpleModal } from "../states/simple-modal"; -import { z } from "zod"; - -let friendsTable: SortedTable | undefined = undefined; - -let pendingRequests: Connection[] | undefined; -let friendsList: Friend[] | undefined; - -async function fetchPendingConnections(): Promise { - const result = await Ape.connections.get({ - query: { status: "pending", type: "incoming" }, - }); - - if (result.status !== 200) { - showErrorNotification(`Error getting connections: ${result.body.message}`); - pendingRequests = undefined; - } else { - pendingRequests = result.body.data; - DB.mergeConnections(pendingRequests); - } -} - -function updatePendingConnections(): void { - qs(".pageFriends .pendingRequests")?.hide(); - - if (pendingRequests === undefined || pendingRequests.length === 0) { - qs(".pageFriends .pendingRequests")?.hide(); - } else { - qs(".pageFriends .pendingRequests")?.show(); - - const html = pendingRequests - .map( - (item) => ` - ${item.initiatorName} - - - ${formatAge(item.lastModified)} ago - - - - - - - - `, - ) - .join("\n"); - - qs(".pageFriends .pendingRequests tbody")?.setHtml(html); - } -} - -async function fetchFriends(): Promise { - const result = await Ape.users.getFriends(); - if (result.status !== 200) { - showErrorNotification(`Error getting friends: ${result.body.message}`); - friendsList = undefined; - } else { - friendsList = result.body.data; - } -} - -function updateFriends(): void { - qs(".pageFriends .friends .nodata")?.hide(); - qs(".pageFriends .friends table")?.hide(); - - qs(".pageFriends .friends .error")?.hide(); - - if (friendsList === undefined || friendsList.length === 0) { - qs(".pageFriends .friends table")?.hide(); - qs(".pageFriends .friends .nodata")?.show(); - } else { - qs(".pageFriends .friends table")?.show(); - qs(".pageFriends .friends .nodata")?.hide(); - - if (friendsTable === undefined) { - friendsTable = new SortedTable({ - table: qsr(".pageFriends .friends table"), - data: friendsList, - buildRow: buildFriendRow, - persistence: new LocalStorageWithSchema({ - key: "friendsListSort", - schema: SortSchema, - fallback: { property: "name", descending: false }, - }), - }); - } else { - friendsTable.setData(friendsList); - } - friendsTable.updateBody(); - } -} - -function buildFriendRow(entry: Friend): HTMLTableRowElement { - const xpDetails = getXpDetails(entry.xp ?? 0); - const testStats = formatTypingStatsRatio(entry); - - const top15 = formatPb(entry.top15); - const top60 = formatPb(entry.top60); - - const element = document.createElement("tr"); - element.dataset["connectionId"] = entry.connectionId; - - const isMe = entry.uid === getAuthenticatedUser()?.uid; - - let actions = ""; - if (isMe) { - element.classList.add("me"); - } else { - actions = ``; - } - element.innerHTML = ` - -
-
- ${ - entry.name - }
- ${getHtmlByUserFlags(entry)} - ${ - isSafeNumber(entry.badgeId) - ? getBadgeHTMLbyId(entry.badgeId) - : "" - } -
-
- - ${ - entry.lastModified !== undefined - ? formatAge(entry.lastModified, "short") - : "-" - } - - ${xpDetails.level} - - ${ - entry.completedTests - }/${entry.startedTests} - ${secondsToString( - Math.round(entry.timeTyping ?? 0), - true, - true, - )} - - ${formatStreak(entry.streak?.length)} - - ${ - top15?.wpm ?? "-" - }
${top15?.acc ?? "-"}
- ${ - top60?.wpm ?? "-" - }
${top60?.acc ?? "-"}
- - ${actions} - - - `; - - element - .querySelector(".avatarPlaceholder") - ?.replaceWith(getAvatarElement(entry)); - return element; -} - -function formatAge( - timestamp: number | undefined, - format?: "short" | "full", -): string { - if (timestamp === undefined) return ""; - let formatted = ""; - const duration = intervalToDuration({ start: timestamp, end: Date.now() }); - - if (format === undefined || format === "full") { - formatted = formatDuration(duration, { - format: ["years", "months", "days", "hours", "minutes"], - }); - } else { - formatted = formatDistanceToNow(timestamp); - } - - return formatted !== "" ? formatted : "less then a minute"; -} - -function formatPb(entry?: PersonalBest): - | { - wpm: string; - acc: string; - raw: string; - con: string; - details: string; - } - | undefined { - if (entry === undefined) { - return undefined; - } - const result = { - wpm: Format.typingSpeed(entry.wpm, { showDecimalPlaces: true }), - acc: Format.percentage(entry.acc, { showDecimalPlaces: true }), - raw: Format.typingSpeed(entry.raw, { showDecimalPlaces: true }), - con: Format.percentage(entry.consistency, { showDecimalPlaces: true }), - details: "", - }; - - const details = [ - `${getLanguageDisplayString(entry.language)}`, - `${result.wpm} wpm`, - ]; - - if (isSafeNumber(entry.acc)) { - details.push(`${result.acc} acc`); - } - if (isSafeNumber(entry.raw)) { - details.push(`${result.raw} raw`); - } - if (isSafeNumber(entry.consistency)) { - details.push(`${result.con} con`); - } - if (isSafeNumber(entry.timestamp)) { - details.push(`${dateFormat(entry.timestamp, "dd MMM yyyy")}`); - } - - result.details = details.join("\n"); - - return result; -} - -function formatStreak(length?: number, prefix?: string): string { - if (length === 1) return "-"; - return isSafeNumber(length) - ? `${prefix !== undefined ? `${prefix} ` : ""}${length} days` - : "-"; -} - -qs(".pageFriends button.friendAdd")?.on("click", () => - showSimpleModal({ - title: "Add a friend", - buttonText: "request", - schema: z.object({ receiverName: UserNameWithoutFilterSchema }), - inputs: { - receiverName: { - placeholder: "user name", - type: "text", - validation: { - isValid: remoteValidation( - async (name: string) => - Ape.users.getNameAvailability({ params: { name } }), - { check: (data) => !data.available || "Unknown user" }, - ), - debounceDelay: 1000, - }, - }, - }, - - execFn: async ({ receiverName }) => { - const result = await addFriend(receiverName); - - if (result === true) { - updatePendingConnections(); - return { - status: "success", - message: `Request sent to ${receiverName}`, - }; - } - - let status: "error" | "notice" | "success" = "error"; - let message: string = "Unknown error"; - - if (result.includes("already exists")) { - status = "notice"; - message = `You are already friends with ${receiverName}`; - } else if (result.includes("request already sent")) { - status = "notice"; - message = `You have already sent a friend request to ${receiverName}`; - } else if (result.includes("blocked by initiator")) { - status = "notice"; - message = `You have blocked ${receiverName}`; - } else if (result.includes("blocked by receiver")) { - status = "notice"; - message = `${receiverName} has blocked you`; - } - - return { status, message, alwaysHide: true }; - }, - }), -); - -// need to set the listener for action buttons on the table because the table content is getting replaced -qs(".pageFriends .pendingRequests table")?.on("click", async (e) => { - const target = e.target as HTMLElement; - const action = Array.from(target.classList).find((it) => - ["accepted", "rejected", "blocked"].includes(it), - ) as "accepted" | "rejected" | "blocked"; - - if (action === undefined) return; - - const row = target.closest("tr") as HTMLElement; - const id = row.dataset["id"]; - if (id === undefined) { - throw new Error("Cannot find id of target."); - } - row.querySelectorAll("button").forEach((button) => (button.disabled = true)); - - showLoaderBar(); - const result = - action === "rejected" - ? await Ape.connections.delete({ - params: { id }, - }) - : await Ape.connections.update({ - params: { id }, - body: { status: action }, - }); - hideLoaderBar(); - - if (result.status !== 200) { - showErrorNotification( - `Cannot update friend request: ${result.body.message}`, - ); - } else { - //remove from cache - pendingRequests = pendingRequests?.filter((it) => it._id !== id); - updatePendingConnections(); - - const snapshot = DB.getSnapshot(); - if (snapshot) { - const receiverUid = row.dataset["receiverUid"]; - if (receiverUid === undefined) { - throw new Error("Cannot find receiverUid of target."); - } - - if (action === "rejected") { - // oxlint-disable-next-line no-dynamic-delete, no-unsafe-member-access - delete snapshot.connections[receiverUid]; - } else { - snapshot.connections[receiverUid] = action; - } - DB.setSnapshot(snapshot); - } - - if (action === "blocked") { - showNoticeNotification(`User has been blocked`); - } - if (action === "accepted") { - showSuccessNotification(`Request accepted`); - } - if (action === "rejected") { - showNoticeNotification(`Request rejected`); - } - - if (action === "accepted") { - showSpinner(); - await fetchFriends(); - updateFriends(); - hideSpinner(); - } - } -}); -// need to set the listener for action buttons on the table because the table content is getting replaced -qs(".pageFriends .friends table")?.on("click", async (e) => { - const target = e.target as HTMLElement; - const action = Array.from(target.classList).find((it) => - ["remove"].includes(it), - ); - - if (action === undefined) return; - - const row = target.closest("tr") as HTMLElement; - const connectionId = row.dataset["connectionId"]; - if (connectionId === undefined) { - throw new Error("Cannot find id of target."); - } - - if (action === "remove") { - const name = row.querySelector("a.entryName")?.textContent ?? ""; - - showSimpleModal({ - title: "Remove friend", - buttonText: "remove friend", - text: `Are you sure you want to remove ${name} as a friend?`, - - execFn: async () => { - const result = await Ape.connections.delete({ - params: { id: connectionId }, - }); - if (result.status !== 200) { - return { status: "error", message: result.body.message }; - } else { - friendsList = friendsList?.filter( - (it) => it.connectionId !== connectionId, - ); - friendsTable?.setData(friendsList ?? []); - friendsTable?.updateBody(); - return { status: "success", message: `Friend removed` }; - } - }, - }); - } -}); - -function showSpinner(): void { - document.querySelector(".friends .spinner")?.classList.remove("hidden"); -} - -function hideSpinner(): void { - document.querySelector(".friends .spinner")?.classList.add("hidden"); -} - -function update(): void { - updatePendingConnections(); - updateFriends(); -} - -export const page = new Page({ - id: "friends", - display: "Friends", - element: qsr(".page.pageFriends"), - path: "/friends", - loadingOptions: { - loadingMode: () => { - if (!getAuthenticatedUser()) { - return "none"; - } - const hasCache = - friendsList !== undefined && pendingRequests !== undefined; - - if (hasCache) { - return { - mode: "async", - beforeLoading: showSpinner, - afterLoading: () => { - hideSpinner(); - update(); - }, - }; - } else { - return "sync"; - } - }, - - loadingPromise: async () => { - await ServerConfiguration.configurationPromise; - const serverConfig = ServerConfiguration.get(); - if (!serverConfig?.connections.enabled) { - throw new Error("Connectins are disabled."); - } - - await Promise.all([fetchPendingConnections(), fetchFriends()]); - }, - style: "bar", - keyframes: [ - { percentage: 50, durationMs: 1500, text: "Downloading friends..." }, - { - percentage: 50, - durationMs: 1500, - text: "Downloading friend requests...", - }, - ], - }, - - afterHide: async (): Promise => { - Skeleton.remove("pageFriends"); - }, - beforeShow: async (): Promise => { - Skeleton.append("pageFriends", "main"); - update(); - }, -}); - -onDOMReady(() => { - Skeleton.save("pageFriends"); -}); - -authEvent.subscribe((event) => { - if (event.type === "authStateChanged" && !event.data.isUserSignedIn) { - pendingRequests = undefined; - friendsList = undefined; - } -}); diff --git a/frontend/src/ts/queries/friends.ts b/frontend/src/ts/queries/friends.ts new file mode 100644 index 000000000000..8cc9560bd10a --- /dev/null +++ b/frontend/src/ts/queries/friends.ts @@ -0,0 +1,29 @@ +import { queryOptions } from "@tanstack/solid-query"; +import { baseKey } from "./utils/keys"; +import Ape from "../ape"; +import { queryClient } from "."; + +const queryKeys = { + root: () => baseKey("friendsList", { isUserSpecific: true }), +}; + +// oxlint-disable-next-line typescript/explicit-function-return-type +export const getFriendsListQuery = () => + queryOptions({ + queryKey: queryKeys.root(), + queryFn: async () => { + const response = await Ape.users.getFriends(); + if (response.status !== 200) { + throw new Error( + `Failed to load friends list: ${response.body.message}`, + ); + } + return response.body.data; + }, + }); + +export async function invalidateFriendsList(): Promise { + await queryClient.invalidateQueries({ + queryKey: queryKeys.root(), + }); +} diff --git a/frontend/src/ts/states/core.ts b/frontend/src/ts/states/core.ts index 6e500553b8ca..806485607e63 100644 --- a/frontend/src/ts/states/core.ts +++ b/frontend/src/ts/states/core.ts @@ -1,7 +1,9 @@ -import { createSignal } from "solid-js"; +import { createMemo, createSignal } from "solid-js"; import { CommandlineSubgroupKey } from "../commandline/types"; import { PageName } from "../pages/page"; import { showModal } from "./modals"; +import { Formatting } from "../utils/format"; +import { getConfig } from "../config/store"; export const [getActivePage, setActivePage] = createSignal("loading"); export const [getVersion, setVersion] = createSignal<{ @@ -49,3 +51,10 @@ export function showCommandLineForConfig( export const [getCustomTextIndicator, setCustomTextIndicator] = createSignal< { name: string; isLong: boolean } | undefined >(undefined); + +export const getFormatting = createMemo(() => { + return new Formatting({ + alwaysShowDecimalPlaces: getConfig.alwaysShowDecimalPlaces, + typingSpeedUnit: getConfig.typingSpeedUnit, + }); +}); diff --git a/frontend/src/ts/utils/date-and-time.ts b/frontend/src/ts/utils/date-and-time.ts index b31ebfbf77fc..ceb8910f0985 100644 --- a/frontend/src/ts/utils/date-and-time.ts +++ b/frontend/src/ts/utils/date-and-time.ts @@ -1,5 +1,10 @@ import { roundTo2 } from "@monkeytype/util/numbers"; -import { Day } from "date-fns"; +import { + Day, + formatDistanceToNow, + formatDuration, + intervalToDuration, +} from "date-fns"; /** * Converts seconds to a human-readable string representation of time. @@ -251,3 +256,22 @@ export function getFirstDayOfTheWeek(): Day { return 0; //start on sunday } + +export function formatAge( + timestamp: number | undefined, + format?: "short" | "full", +): string { + if (timestamp === undefined) return ""; + let formatted = ""; + const duration = intervalToDuration({ start: timestamp, end: Date.now() }); + + if (format === undefined || format === "full") { + formatted = formatDuration(duration, { + format: ["years", "months", "days", "hours", "minutes"], + }); + } else { + formatted = formatDistanceToNow(timestamp); + } + + return formatted !== "" ? formatted : "less than a minute"; +} From 379b2efc2d59e1401211eb25997051e9d5e21e58 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 12 Jun 2026 17:16:09 +0200 Subject: [PATCH 4/4] chore: remove broken ci workflow --- .github/workflows/claude-code-review.yml | 40 ------------------------ 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index a7f5d5cdf9f1..000000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - if: >- - contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: "https://github.com/anthropics/claude-code.git" - plugins: "code-review@claude-code-plugins" - prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options