From 59f9d6c646e998fbe3c76105c1defe0c03ab98a7 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 26 Jan 2026 12:36:48 +0100 Subject: [PATCH 01/26] feat: add DataTable component (@fehmer) --- frontend/package.json | 1 + .../src/ts/components/ui/table/DataTable.tsx | 164 ++++++++++++++++++ frontend/src/ts/components/ui/table/Table.tsx | 87 ++++++++++ .../components/ui/table/TableColumnHeader.tsx | 50 ++++++ frontend/src/ts/signals/breakpoints.ts | 10 +- frontend/src/ts/types/tanstack-table.d.ts | 26 +++ frontend/tsconfig.json | 3 +- pnpm-lock.yaml | 82 +++------ 8 files changed, 358 insertions(+), 65 deletions(-) create mode 100644 frontend/src/ts/components/ui/table/DataTable.tsx create mode 100644 frontend/src/ts/components/ui/table/Table.tsx create mode 100644 frontend/src/ts/components/ui/table/TableColumnHeader.tsx create mode 100644 frontend/src/ts/types/tanstack-table.d.ts diff --git a/frontend/package.json b/frontend/package.json index bac5f695da18..af7aafda140a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@sentry/browser": "9.14.0", "@sentry/vite-plugin": "3.3.1", "@solidjs/meta": "0.29.4", + "@tanstack/solid-table": "8.21.3", "@ts-rest/core": "3.52.1", "animejs": "4.2.2", "balloon-css": "1.2.0", diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx new file mode 100644 index 000000000000..75bb57ec3434 --- /dev/null +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -0,0 +1,164 @@ +import { + AccessorKeyColumnDef, + ColumnDef, + createSolidTable, + flexRender, + getCoreRowModel, + getSortedRowModel, + SortingState, +} from "@tanstack/solid-table"; +import { createMemo, For, JSXElement, Show } from "solid-js"; +import { z } from "zod"; + +import { useLocalStorage } from "../../../hooks/useLocalStorage"; +import { bp } from "../../../signals/breakpoints"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "./Table"; + +const SortingStateSchema = z.array( + z.object({ + desc: z.boolean(), + id: z.string(), + }), +); + +export type AnyColumnDef = + | ColumnDef + // | AccessorFnColumnDef + | AccessorKeyColumnDef; + +type DataTableProps = { + id: string; + columns: AnyColumnDef[]; + data: TData[]; + fallback?: JSXElement; +}; + +export function DataTable( + // oxlint-disable-next-line typescript/no-explicit-any + props: DataTableProps, +): JSXElement { + const [sorting, setSorting] = useLocalStorage({ + //oxlint-disable-next-line solid/reactivity + key: `${props.id}Sort`, + schema: SortingStateSchema, + fallback: [], + //migrate old state from sorted-table + migrate: (value: Record | unknown[]) => + typeof value === "object" && "property" in value && "descending" in value + ? [ + { + id: value["property"] as string, + desc: value["descending"] as boolean, + }, + ] + : [], + }); + + const columnVisibility = createMemo(() => { + const current = bp(); + const result = Object.fromEntries( + props.columns.map((col) => { + //fill missing columnIds, otherwise hidinc columns will not work + if (col.id === undefined) { + if ("accessorKey" in col) { + col.id = col.accessorKey as string; + } + } + return [col.id as string, current[col.meta?.breakpoint ?? "xxs"]]; + }), + ); + + return result; + }); + + const table = createSolidTable({ + get data() { + return props.data; + }, + get columns() { + return props.columns; + }, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + state: { + get sorting() { + return sorting(); + }, + get columnVisibility() { + return columnVisibility(); + }, + }, + }); + + return ( + + + + + {(headerGroup) => ( + + + {(header) => ( + + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + )} + + + )} + + + + + {(row) => ( + + + {(cell) => { + const cellMeta = + typeof cell.column.columnDef.meta?.cellMeta === "function" + ? cell.column.columnDef.meta.cellMeta({ + value: cell.getValue(), + row: cell.row.original, + }) + : (cell.column.columnDef.meta?.cellMeta ?? {}); + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + }} + + + )} + + +
+
+ ); +} diff --git a/frontend/src/ts/components/ui/table/Table.tsx b/frontend/src/ts/components/ui/table/Table.tsx new file mode 100644 index 000000000000..ca8943f312ac --- /dev/null +++ b/frontend/src/ts/components/ui/table/Table.tsx @@ -0,0 +1,87 @@ +import type { Component, ComponentProps } from "solid-js"; +import { splitProps } from "solid-js"; + +import { cn } from "../../../utils/cn"; + +const Table: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( +
+ ); +}; + +const TableHeader: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + tr]:bg-none", local.class)} + {...others} + > + ); +}; + +const TableBody: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + tr]:odd:bg-sub-alt text-xs md:text-sm lg:text-base", + local.class, + )} + {...others} + > + ); +}; + +const TableFooter: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +const TableRow: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + td]:first:rounded-l [&>td]:last:rounded-r", local.class)} + {...others} + > + ); +}; + +const TableHead: Component> = (props) => { + const [local, others] = splitProps(props, ["class", "aria-label"]); + return ( + + ); +}; + +const TableCell: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +const TableCaption: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +}; diff --git a/frontend/src/ts/components/ui/table/TableColumnHeader.tsx b/frontend/src/ts/components/ui/table/TableColumnHeader.tsx new file mode 100644 index 000000000000..eb68a3c61bf8 --- /dev/null +++ b/frontend/src/ts/components/ui/table/TableColumnHeader.tsx @@ -0,0 +1,50 @@ +import { Column } from "@tanstack/solid-table"; +import { + ComponentProps, + JSXElement, + Match, + Show, + splitProps, + Switch, +} from "solid-js"; + +import { cn } from "../../../utils/cn"; + +type TableColumnHeaderProps = ComponentProps<"button"> & { + column: Column; + title: string; +}; + +export function TableColumnHeader( + props: TableColumnHeaderProps, +): JSXElement { + const [local, others] = splitProps(props, ["column", "title", "class"]); + + return ( + + + + ); +} diff --git a/frontend/src/ts/signals/breakpoints.ts b/frontend/src/ts/signals/breakpoints.ts index e1664f25718c..d007aa935686 100644 --- a/frontend/src/ts/signals/breakpoints.ts +++ b/frontend/src/ts/signals/breakpoints.ts @@ -1,11 +1,11 @@ import { Accessor, createSignal, onCleanup } from "solid-js"; import { debounce } from "throttle-debounce"; -type BreakpointKeys = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; -type Breakpoints = Record; +export type BreakpointKey = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; +type Breakpoints = Record; const styles = getComputedStyle(document.documentElement); -const tw: Record = { +const tw: Record = { xxs: 0, xs: parseInt(styles.getPropertyValue("--breakpoint-xs")), sm: parseInt(styles.getPropertyValue("--breakpoint-sm")), @@ -18,7 +18,7 @@ const tw: Record = { export const bp = createBreakpoints(tw); function createBreakpoints( - breakpoints: Record, + breakpoints: Record, ): Accessor { const queries = Object.fromEntries( Object.entries(breakpoints).map(([key, px]) => [ @@ -49,5 +49,5 @@ function createBreakpoints( } }); - return matches as Accessor>; + return matches as Accessor>; } diff --git a/frontend/src/ts/types/tanstack-table.d.ts b/frontend/src/ts/types/tanstack-table.d.ts new file mode 100644 index 000000000000..4a748e51cc0a --- /dev/null +++ b/frontend/src/ts/types/tanstack-table.d.ts @@ -0,0 +1,26 @@ +import "@tanstack/solid-table"; +import type { JSX } from "solid-js"; +import { BreakpointKey } from "../signals/breakpoints"; + +declare module "@tanstack/solid-table" { + //This needs to be an interface + // oxlint-disable-next-line typescript/consistent-type-definitions + interface ColumnMeta { + /** + * define minimal breakpoint for the column to be visible. + * If not set, the column is always visible + */ + breakpoint?: BreakpointKey; + + /** + * additional attributes to be set on the table cell. + * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` + */ + cellMeta?: + | JSX.HTMLAttributes + | ((ctx: { + value: TValue; + row: TData; + }) => JSX.HTMLAttributes); + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 467adc1eec01..c8efba5ed40c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,7 +22,8 @@ "./src/**/*.tsx", "./scripts/**/*.ts", "vite-plugins/**/*.ts", - "vite.config.ts" + "vite.config.ts", + "./src/types/**/*.d.ts" ], "exclude": ["node_modules", "build", "setup-tests.ts", "./__tests__/**/*.*"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25366c8d830d..a704e458ba8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,9 @@ importers: '@solidjs/meta': specifier: 0.29.4 version: 0.29.4(solid-js@1.9.10) + '@tanstack/solid-table': + specifier: 8.21.3 + version: 8.21.3(solid-js@1.9.10) '@ts-rest/core': specifier: 3.52.1 version: 3.52.1(@types/node@24.9.1)(zod@3.23.8) @@ -417,7 +420,7 @@ importers: version: 5.0.2 '@vitest/coverage-v8': specifier: 4.0.15 - version: 4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) + version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.31) @@ -510,7 +513,7 @@ importers: version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) vitest: specifier: 4.0.15 - version: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + version: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) packages/contracts: dependencies: @@ -705,7 +708,7 @@ packages: resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} peerDependencies: openapi3-ts: ^2.0.0 || ^3.0.0 - zod: 3.23.8 + zod: ^3.20.0 '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} @@ -3376,6 +3379,16 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/solid-table@8.21.3': + resolution: {integrity: sha512-PmhfSLBxVKiFs01LtYOYrCRhCyTUjxmb4KlxRQiqcALtip8+DOJeeezQM4RSX/GUS0SMVHyH/dNboCpcO++k2A==} + engines: {node: '>=12'} + peerDependencies: + solid-js: '>=1.3' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -13064,6 +13077,13 @@ snapshots: tailwindcss: 4.1.18 vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + '@tanstack/solid-table@8.21.3(solid-js@1.9.10)': + dependencies: + '@tanstack/table-core': 8.21.3 + solid-js: 1.9.10 + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -13497,23 +13517,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.15 - ast-v8-to-istanbul: 0.3.8 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magicast: 0.5.1 - obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.0.0 @@ -20694,45 +20697,6 @@ snapshots: - tsx - yaml - vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1): - dependencies: - '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) - '@vitest/pretty-format': 4.0.15 - '@vitest/runner': 4.0.15 - '@vitest/snapshot': 4.0.15 - '@vitest/spy': 4.0.15 - '@vitest/utils': 4.0.15 - es-module-lexer: 1.7.0 - expect-type: 1.2.2 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.9.1 - happy-dom: 20.0.10 - jsdom: 27.4.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - vlq@0.2.3: {} w3c-xmlserializer@5.0.0: From 14801fd6aedaedd542e88169b1e686560b4c63ee Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 25 Jan 2026 20:47:10 +0100 Subject: [PATCH 02/26] refactor: add asyncStore (@fehmer) --- .../components/AsyncContent.spec.tsx | 111 +++++++-- frontend/__tests__/hooks/asyncStore.spec.tsx | 214 +++++++++++++++++ .../src/ts/components/common/AsyncContent.tsx | 97 +++++--- frontend/src/ts/hooks/asyncStore.ts | 219 ++++++++++++++++++ frontend/src/ts/utils/misc.ts | 6 +- 5 files changed, 598 insertions(+), 49 deletions(-) create mode 100644 frontend/__tests__/hooks/asyncStore.spec.tsx create mode 100644 frontend/src/ts/hooks/asyncStore.ts diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx index 0147be215dd3..e7ba7136e0b2 100644 --- a/frontend/__tests__/components/AsyncContent.spec.tsx +++ b/frontend/__tests__/components/AsyncContent.spec.tsx @@ -3,25 +3,9 @@ import { createResource, Resource } from "solid-js"; import { describe, it, expect } from "vitest"; import AsyncContent from "../../src/ts/components/common/AsyncContent"; +import { AsyncStore, createAsyncStore } from "../../src/ts/hooks/asyncStore"; describe("AsyncContent", () => { - function renderWithResource( - resource: Resource, - errorMessage?: string, - ): { - container: HTMLElement; - } { - const { container } = render(() => ( - - {(data) =>
{String(data)}
} -
- )); - - return { - container, - }; - } - it("renders loading state while resource is pending", () => { const [resource] = createResource(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -76,4 +60,97 @@ describe("AsyncContent", () => { expect(screen.getByText(/An error occurred/)).toBeInTheDocument(); }); }); + + it("renders loading state while asyncStore is pending", () => { + const asyncStore = createAsyncStore({ + name: "test", + fetcher: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return { data: "data" }; + }, + }); + + const { container } = renderWithAsyncStore(asyncStore); + + const preloader = container.querySelector(".preloader"); + expect(preloader).toBeInTheDocument(); + expect(preloader).toHaveClass("preloader"); + expect(preloader?.querySelector("i")).toHaveClass( + "fas", + "fa-fw", + "fa-spin", + "fa-circle-notch", + ); + }); + + it("renders data when asyncStore resolves", async () => { + const asyncStore = createAsyncStore<{ data?: string }>({ + name: "test", + fetcher: async () => { + return { data: "Test Data" }; + }, + autoLoad: true, + }); + + renderWithAsyncStore(asyncStore); + + await waitFor(() => { + expect(screen.getByTestId("content")).toHaveTextContent("Test Data"); + }); + }); + + it.skip("renders error message when asyncStore fails", async () => { + const asyncStore = createAsyncStore({ + name: "test", + fetcher: async () => { + throw new Error("Test error"); + }, + }); + + try { + renderWithAsyncStore(asyncStore as any, "Custom error message"); + } catch {} + + await expect(() => asyncStore.ready()).rejects; + await waitFor(() => { + expect( + screen.getByText(/Custom error message: Test error/), + ).toBeInTheDocument(); + }); + }); + + function renderWithResource( + resource: Resource, + errorMessage?: string, + ): { + container: HTMLElement; + } { + const { container } = render(() => ( + + {(data) =>
{String(data)}
} +
+ )); + + return { + container, + }; + } + + function renderWithAsyncStore( + asyncStore: AsyncStore<{ data?: string }>, + errorMessage?: string, + ): { + container: HTMLElement; + } { + asyncStore.load(); + const { container } = render(() => ( + + {(data) => {data.data}} + + )); + + return { + container, + }; + } }); diff --git a/frontend/__tests__/hooks/asyncStore.spec.tsx b/frontend/__tests__/hooks/asyncStore.spec.tsx new file mode 100644 index 000000000000..5b2cb6582a8f --- /dev/null +++ b/frontend/__tests__/hooks/asyncStore.spec.tsx @@ -0,0 +1,214 @@ +import { render, waitFor } from "@solidjs/testing-library"; +import { For } from "solid-js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createAsyncStore } from "../../src/ts/hooks/asyncStore"; + +const fetcher = vi.fn(); +const initialValue = vi.fn(() => ({ data: null })); + +describe("createAsyncStore", () => { + beforeEach(() => { + fetcher.mockClear(); + initialValue.mockClear(); + }); + + it("should initialize with the correct state", () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + + expect(store.state().state).toBe("unresolved"); + expect(store.state().loading).toBe(false); + expect(store.state().ready).toBe(false); + expect(store.state().refreshing).toBe(false); + expect(store.state().error).toBeUndefined(); + expect(store.store()).toEqual({ data: null }); + }); + + it("should transition to loading when load is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + store.load(); + + expect(store.state().state).toBe("pending"); + expect(store.state().loading).toBe(true); + }); + + it("should enable loading if ready is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + + await store.ready(); + }); + + it("should call the fetcher when load is called", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(store.state().state).toBe("ready"); + expect(store.store()).toEqual({ data: "test" }); + }); + + it("should handle error when fetcher fails", async () => { + fetcher.mockRejectedValueOnce(new Error("Failed to load")); + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + + store.load(); + + await expect(store.ready()).rejects.toThrow("Failed to load"); + + expect(store.state().state).toBe("errored"); + expect(store.state().error).toEqual(new Error("Failed to load")); + }); + + it("should transition to refreshing state on refresh", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + store.refresh(); // trigger refresh + expect(store.state().state).toBe("refreshing"); + expect(store.state().refreshing).toBe(true); + }); + + it("should trigger load when refresh is called and shouldLoad is false", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + expect(store.state().state).toBe("unresolved"); + + store.refresh(); + expect(store.state().state).toBe("refreshing"); + expect(store.state().refreshing).toBe(true); + + // Wait for the store to be ready after fetching + await store.ready(); + + // Ensure the store's state is 'ready' after the refresh + expect(store.state().state).toBe("ready"); + expect(store.store()).toEqual({ data: "test" }); + }); + + it("should reset the store to its initial value on reset", async () => { + const store = createAsyncStore({ name: "test", fetcher, initialValue }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + expect(store.store()).toEqual({ data: "test" }); + + store.reset(); + expect(store.state().state).toBe("unresolved"); + expect(store.state().loading).toBe(false); + expect(store.store()).toEqual({ data: null }); + }); + + it("should persist changes", async () => { + const persist = vi.fn(); + persist.mockResolvedValue({}); + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + persist, + }); + fetcher.mockResolvedValueOnce({ data: "test" }); + store.load(); + + await store.ready(); + + store.update({ data: "newValue" }); + expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); + }); + + it("fails updating when not ready", async () => { + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + }); + + expect(() => store.update({})).toThrowError( + "Store test cannot update in state unresolved", + ); + }); + + it("should be reactive", async () => { + const store = createAsyncStore<{ + data: string; + nested?: { number: number }; + list: string[]; + }>({ name: "test", fetcher }); + fetcher.mockResolvedValueOnce({ + data: "test", + nested: { number: 1 }, + list: ["Bob", "Kevin"], + }); + fetcher.mockResolvedValueOnce({ + data: "updated", + nested: { number: 2 }, + list: ["Bob", "Stuart"], + }); + + const { container } = render(() => ( + + State: {store.state().state}
+ Loading: {store.state().loading ? "true" : "false"}
+ Data: {store.store()?.data ?? "empty"}
+ Number: {store.store()?.nested?.number ?? "no number"}; List:{" "} + + {(item) => {item},} + +
+ )); + + //initial state + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + + //load + store.load(); + expect(container.textContent).toContain("Loading: true"); + expect(container.textContent).toContain("State: pending"); + expect(container.textContent).toContain("Data: empty"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + + //resource loaded successfull + await store.ready(); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 1"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + + //modify + store.update({ nested: { number: 3 } }); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: test"); + expect(container.textContent).toContain("Number: 3"); + expect(container.textContent).toContain("List: Bob,Kevin,"); + + //refresh + store.refresh(); + await store.ready(); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: ready"); + expect(container.textContent).toContain("Data: updated"); + expect(container.textContent).toContain("Number: 2"); + expect(container.textContent).toContain("List: Bob,Stuart,"); + + //reset back to initial state + store.reset(); + await waitFor(() => + store.state().state === "unresolved" ? true : undefined, + ); + expect(container.textContent).toContain("Loading: false"); + expect(container.textContent).toContain("State: unresolved"); + expect(container.textContent).toContain("Number: no number"); + expect(container.textContent).toContain("List: no list"); + }); +}); diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index 5d235672e904..443ff1fc4973 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -1,6 +1,13 @@ -import { ErrorBoundary, JSXElement, Resource, Show, Suspense } from "solid-js"; +import { + createMemo, + ErrorBoundary, + JSXElement, + Resource, + Show, +} from "solid-js"; import * as Notifications from "../../elements/notifications"; +import { AsyncStore } from "../../hooks/asyncStore"; import { createErrorMessage } from "../../utils/misc"; import { Conditional } from "./Conditional"; @@ -8,23 +15,44 @@ import { Fa } from "./Fa"; export default function AsyncContent( props: { - resource: Resource; errorMessage?: string; } & ( | { - alwaysShowContent?: never; - children: (data: T) => JSXElement; + resource: Resource; + asyncStore?: never; } | { - alwaysShowContent: true; - showLoader?: true; - children: (data: T | undefined) => JSXElement; + asyncStore: AsyncStore; + resource?: never; } - ), + ) & + ( + | { + alwaysShowContent?: never; + children: (data: T) => JSXElement; + } + | { + alwaysShowContent: true; + showLoader?: true; + children: (data: T | undefined) => JSXElement; + } + ), ): JSXElement { + const source = createMemo(() => + props.resource !== undefined + ? { + value: props.resource, + loading: () => props.resource.loading, + } + : { + value: props.asyncStore.store, + loading: () => props.asyncStore.state().loading, + }, + ); + const value = () => { try { - return props.resource(); + return source().value; } catch (err) { const message = createErrorMessage( err, @@ -36,10 +64,19 @@ export default function AsyncContent( } }; const handleError = (err: unknown): string => { - console.error(err); + console.error("AsyncContext failed", err); return createErrorMessage(err, props.errorMessage ?? "An error occurred"); }; + const loader: JSXElement = ( +
+ +
+ ); + + const errorText = (err: unknown): JSXElement => ( +
{handleError(err)}
+ ); return ( ( }; return ( <> - -
- -
-
- {p.children(value())} + {loader} + {p.children(value()?.())} ); })()} else={ -
{handleError(err)}
} - > - - - + + + {errorText(props.asyncStore?.state().error)} + + + + {props.children(source().value() as T)} + } - > - - {props.children(props.resource() as T)} - - + />
} /> diff --git a/frontend/src/ts/hooks/asyncStore.ts b/frontend/src/ts/hooks/asyncStore.ts new file mode 100644 index 000000000000..62ada670a932 --- /dev/null +++ b/frontend/src/ts/hooks/asyncStore.ts @@ -0,0 +1,219 @@ +import type { Accessor } from "solid-js"; +import { createEffect, createResource, createSignal } from "solid-js"; +import { createStore, reconcile, Store } from "solid-js/store"; +import { promiseWithResolvers } from "../utils/misc"; + +export type LoadError = Error | { message?: string }; +type State = + | { + state: "unresolved"; + loading: false; + ready: false; + refreshing: false; + error?: undefined; + } + | { + state: "pending"; + loading: true; + ready: false; + refreshing: false; + error?: undefined; + } + | { + state: "ready"; + loading: false; + ready: true; + refreshing: false; + error?: undefined; + } + | { + state: "refreshing"; + loading: true; + ready: true; + refreshing: true; + error?: undefined; + } + | { + state: "errored"; + loading: false; + ready: false; + refreshing: false; + error: LoadError; + }; + +export type AsyncStore = { + /** + * request store to be loaded + */ + load: () => void; + + /** + * request store to be refreshed + */ + refresh: () => void; + + /** + * reset the resource + store + */ + reset: () => void; + + /** + * store state + */ + state: Accessor; + + /** + * the data store + */ + store: Accessor>; + + /** + * update store with the merged value + */ + update: (value: Partial) => void; + + /** + * promise that resolves when the store is ready. + * rejects if shouldLoad is false + */ + ready: () => Promise; +}; + +export function createAsyncStore({ + name, + fetcher, + persist, + initialValue, + autoLoad, +}: { + name: string; + fetcher: () => Promise; + persist?: (value: T) => Promise; + initialValue?: () => T; + autoLoad?: boolean; +}): AsyncStore { + console.debug(`AsyncStore ${name}: created`); + const [shouldLoad, setShouldLoad] = createSignal(autoLoad ?? false); + const [getState, setState] = createSignal({ + state: "unresolved", + loading: false, + ready: false, + refreshing: false, + error: undefined, + }); + + const [res, { refetch }] = createResource(shouldLoad, async (load) => { + if (!load) return undefined as unknown as T; + return fetcher(); + }); + + const initVal = initialValue?.(); + const [store, setStore] = createStore<{ + available: boolean; + value: T | undefined; + }>({ available: initVal !== undefined, value: initVal }); + let ready = promiseWithResolvers(); + + const resetStore = (): void => { + const fallbackValue = initialValue?.(); + setStore({ + available: fallbackValue !== undefined, + value: fallbackValue, + }); + }; + + const updateState = (state: State["state"], error?: LoadError): void => { + console.debug(`AsyncStore ${name}: update state to ${state}.`); + setState({ + state, + loading: state === "pending", + ready: state === "ready", + refreshing: state === "refreshing", + error: error, + } as State); + }; + + //TODO create effect on resource? + createEffect(() => { + if (!shouldLoad()) return; + if (res.error !== undefined) { + ready.reject(res.error); + updateState(res.state, res.error as LoadError); + resetStore(); + return; + } + + const data = res(); + if (data) { + updateState(res.state); + setStore(reconcile({ available: true, value: data })); + console.debug(`AsyncStore ${name}: updated store to`, store.value); + ready.resolve(data); + } + }); + + createEffect(() => { + if (!shouldLoad()) { + updateState("unresolved"); + return; + } + updateState("pending"); + }); + + const load = (): void => { + if (!shouldLoad()) setShouldLoad(true); + }; + const refresh = (): void => { + if (!shouldLoad()) { + setShouldLoad(true); + } + ready.reset(); + updateState("refreshing"); + void refetch(); + }; + + const reset = (): void => { + setShouldLoad(false); + resetStore(); + updateState("unresolved"); + + // reject any waiters + const oldReady = ready; + ready = promiseWithResolvers(); + oldReady.reject?.(new Error("Reset")); + }; + + return { + load, + refresh, + reset, + state: getState, + store: () => store.value, + update: (value): void => { + if (!getState().ready) { + throw new Error( + `Store ${name} cannot update in state ${getState().state}`, + ); + } + setStore( + reconcile( + { + available: value !== undefined, + value: { ...store.value, ...value } as T, + }, + { merge: true }, + ), + ); + + if (persist !== undefined && store.value !== undefined) { + void persist(store.value).then(() => + console.debug(`Store ${name} persisted.`), + ); + } + }, + ready: async () => { + load(); + return ready.promise; + }, + }; +} diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 8666e9eece3a..f20fe0e6bf18 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -622,7 +622,11 @@ export function promiseWithResolvers(): { }; const reject = (reason?: unknown): void => { - innerReject(reason); + try { + innerReject(reason); + } catch (e) { + //ignore no awaits + } }; return { From d68ac3e033058790cd7630d4b5c862f2aa7dbdfc Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 26 Jan 2026 12:06:25 +0100 Subject: [PATCH 03/26] refetch if persist fails --- frontend/__tests__/hooks/asyncStore.spec.tsx | 23 ++++++++++++++++++++ frontend/src/ts/hooks/asyncStore.ts | 13 ++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/frontend/__tests__/hooks/asyncStore.spec.tsx b/frontend/__tests__/hooks/asyncStore.spec.tsx index 5b2cb6582a8f..85863ed41041 100644 --- a/frontend/__tests__/hooks/asyncStore.spec.tsx +++ b/frontend/__tests__/hooks/asyncStore.spec.tsx @@ -3,6 +3,7 @@ import { For } from "solid-js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createAsyncStore } from "../../src/ts/hooks/asyncStore"; +import { sleep } from "../../src/ts/utils/misc"; const fetcher = vi.fn(); const initialValue = vi.fn(() => ({ data: null })); @@ -120,6 +121,7 @@ describe("createAsyncStore", () => { store.update({ data: "newValue" }); expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); + expect(store.store()?.data).toEqual("newValue"); }); it("fails updating when not ready", async () => { @@ -133,6 +135,27 @@ describe("createAsyncStore", () => { ); }); + it("should refresh if persist fails", async () => { + const persist = vi.fn(); + persist.mockRejectedValue("no good"); + const store = createAsyncStore<{ data: string }>({ + name: "test", + fetcher, + persist, + }); + fetcher.mockResolvedValue({ data: "oldValue" }); + store.load(); + + await store.ready(); + + fetcher.mockClear().mockResolvedValue({ data: "refetchedValue" }); + store.update({ data: "newValue" }); + expect(persist).toHaveBeenCalledExactlyOnceWith({ data: "newValue" }); + + await sleep(100); + expect(store.store()?.data).toEqual("refetchedValue"); + }); + it("should be reactive", async () => { const store = createAsyncStore<{ data: string; diff --git a/frontend/src/ts/hooks/asyncStore.ts b/frontend/src/ts/hooks/asyncStore.ts index 62ada670a932..81e975b21eaa 100644 --- a/frontend/src/ts/hooks/asyncStore.ts +++ b/frontend/src/ts/hooks/asyncStore.ts @@ -180,6 +180,9 @@ export function createAsyncStore({ // reject any waiters const oldReady = ready; ready = promiseWithResolvers(); + oldReady.promise.catch(() => { + /* */ + }); oldReady.reject?.(new Error("Reset")); }; @@ -206,9 +209,13 @@ export function createAsyncStore({ ); if (persist !== undefined && store.value !== undefined) { - void persist(store.value).then(() => - console.debug(`Store ${name} persisted.`), - ); + void persist(store.value) + .then(() => console.debug(`Store ${name} persisted.`)) + .catch((error: unknown) => { + console.debug(`AsyncStore ${name}: persist failed with`, error); + //on error refresh the local store with the remote content + refresh(); + }); } }, ready: async () => { From 9b4409ebdc4a3e549e161923e75b8f4da235d0c4 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 26 Jan 2026 12:38:53 +0100 Subject: [PATCH 04/26] mock userId signal --- frontend/src/ts/firebase.ts | 3 +++ frontend/src/ts/signals/core.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/frontend/src/ts/firebase.ts b/frontend/src/ts/firebase.ts index 85b562673397..564a30d9312e 100644 --- a/frontend/src/ts/firebase.ts +++ b/frontend/src/ts/firebase.ts @@ -35,6 +35,7 @@ import { import { tryCatch } from "@monkeytype/util/trycatch"; import { dispatch as dispatchSignUpEvent } from "./observables/google-sign-up-event"; import { addBanner } from "./stores/banners"; +import { setUserId } from "./signals/core"; let app: FirebaseApp | undefined; let Auth: AuthType | undefined; @@ -75,6 +76,7 @@ export async function init(callback: ReadyCallback): Promise { onAuthStateChanged(Auth, async (user) => { if (!ignoreAuthCallback) { + setUserId(user ? { uid: user?.uid } : null); await callback(true, user); } }); @@ -82,6 +84,7 @@ export async function init(callback: ReadyCallback): Promise { app = undefined; Auth = undefined; console.error("Firebase failed to initialize", e); + setUserId(null); await callback(false, null); if (isDevEnvironment()) { addBanner({ diff --git a/frontend/src/ts/signals/core.ts b/frontend/src/ts/signals/core.ts index f3c6127b08e0..4057aa24a8dd 100644 --- a/frontend/src/ts/signals/core.ts +++ b/frontend/src/ts/signals/core.ts @@ -29,3 +29,7 @@ export const [getCommandlineSubgroup, setCommandlineSubgroup] = createSignal< export const [getFocus, setFocus] = createSignal(false); export const [getGlobalOffsetTop, setGlobalOffsetTop] = createSignal(0); export const [getIsScreenshotting, setIsScreenshotting] = createSignal(false); + +export const [getUserId, setUserId] = createSignal<{ uid: string } | null>( + null, +); From 10323d42dc9f66bee7e2c798f165ce10ed15dcc6 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 26 Jan 2026 12:39:58 +0100 Subject: [PATCH 05/26] --setup-- From 05cb6147e6707f05cf231c296a5591132b3e5629 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 23 Jan 2026 13:02:00 +0100 Subject: [PATCH 06/26] solid friends --- .../components/pages/friends/FriendsList.tsx | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 frontend/src/ts/components/pages/friends/FriendsList.tsx diff --git a/frontend/src/ts/components/pages/friends/FriendsList.tsx b/frontend/src/ts/components/pages/friends/FriendsList.tsx new file mode 100644 index 000000000000..bc29284a9180 --- /dev/null +++ b/frontend/src/ts/components/pages/friends/FriendsList.tsx @@ -0,0 +1,315 @@ +import { PersonalBest } from "@monkeytype/schemas/shared"; +import { Friend } from "@monkeytype/schemas/users"; +import { isSafeNumber } from "@monkeytype/util/numbers"; +import { createColumnHelper } from "@tanstack/solid-table"; +import { format as dateFormat } from "date-fns/format"; +import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; +import { formatDuration } from "date-fns/formatDuration"; +import { intervalToDuration } from "date-fns/intervalToDuration"; +import { createResource, JSXElement } from "solid-js"; + +import Ape from "../../../ape"; +import { getHTMLById } from "../../../controllers/badge-controller"; +import { + getHtmlByUserFlags, + SupportsFlags, +} from "../../../controllers/user-flag-controller"; +import { getActivePage } from "../../../signals/core"; +import { secondsToString } from "../../../utils/date-and-time"; +import Format from "../../../utils/format"; +import { getXpDetails } from "../../../utils/levels"; +import { formatTypingStatsRatio } from "../../../utils/misc"; +import { getLanguageDisplayString } from "../../../utils/strings"; +import AsyncContent from "../../common/AsyncContent"; +import { Button } from "../../common/Button"; +import { DataTable } from "../../ui/table/DataTable"; +import { TableColumnHeader } from "../../ui/table/TableColumnHeader"; + +/* todo test +const sortByNumber = (rowA, rowB, columnId) => + rowA.getValue(columnId) - rowB.getValue(columnId); +*/ + +const FriendName = (props: { + friend: Pick & SupportsFlags; +}): JSXElement => { + return ( +
+
+ + {props.friend.name} + +
+ {getHtmlByUserFlags(props.friend)} + {isSafeNumber(props.friend.badgeId) + ? getHTMLById(props.friend.badgeId) + : ""} +
+
+ ); +}; + +const columnHelper = createColumnHelper(); +const defineColumn = columnHelper.accessor; +const columns = [ + defineColumn("name", { + header: (props) => , + enableSorting: true, + cell: ({ row }) => , + }), + + defineColumn("lastModified", { + header: (props) => ( + + ), + enableSorting: true, + cell: ({ getValue }) => + getValue() === undefined ? "-" : formatAge(getValue(), "short"), + meta: { + cellMeta: ({ value }) => + value === undefined + ? {} + : { + "data-balloon-pos": "down", + "aria-label": `since ${dateFormat(value, "dd MMM yyy HH:mm")}`, + }, + }, + }), + + defineColumn("xp", { + header: (props) => ( + + ), + enableSorting: true, + cell: ({ getValue }) => getXpDetails(getValue() ?? 0).level, + }), + + defineColumn("completedTests", { + header: (props) => ( + + ), + enableSorting: true, + cell: (info) => `${info.getValue()}/${info.row.original.startedTests}`, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => { + const testStats = formatTypingStatsRatio(row); + + return { + "data-balloon-pos": "down", + "aria-label": `${testStats.completedPercentage}% (${ + testStats.restartRatio + } restarts per completed test)`, + }; + }, + }, + }), + + defineColumn("timeTyping", { + header: (props) => ( + + ), + enableSorting: true, + cell: ({ getValue }) => + secondsToString(Math.round(getValue() ?? 0), true, true), + meta: { + breakpoint: "sm", + }, + }), + + defineColumn("streak.length", { + header: (props) => ( + + ), + enableSorting: true, + cell: ({ getValue }) => formatStreak(getValue()), + meta: { + breakpoint: "sm", + cellMeta: ({ row }) => { + const value = row.streak.maxLength as number | undefined; + return value === undefined + ? {} + : { + "data-balloon-pos": "down", + "aria-label": formatStreak(value, "longest streak"), + }; + }, + }, + }), + + defineColumn("top15.wpm", { + header: (props) => ( + + ), + enableSorting: true, + cell: (info) => { + const pb = formatPb(info.row.original.top15); + return ( + <> + {pb?.wpm ?? "-"} +
{pb?.acc ?? "-"}
+ + ); + }, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => ({ + "data-balloon-pos": "down", + "data-balloon-break": "", + "aria-label": formatPb(row.top15 as PersonalBest)?.details, + }), + }, + }), + + defineColumn("top60.wpm", { + header: (props) => ( + + ), + enableSorting: true, + cell: (info) => { + const pb = formatPb(info.row.original.top60); + return ( + <> + {pb?.wpm ?? "-"} +
{pb?.acc ?? "-"}
+ + ); + }, + meta: { + breakpoint: "lg", + cellMeta: ({ row }) => ({ + "data-balloon-pos": "down", + "data-balloon-break": "", + "aria-label": formatPb(row.top60)?.details, + }), + }, + }), + + defineColumn("connectionId", { + header: "", + cell: ({ getValue, row }) => + //check the row is our own user + getValue() !== undefined ? ( + diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index 3c1b47d5c072..904dd67e913b 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -11,6 +11,7 @@ import { Footer } from "./layout/footer/Footer"; import { Overlays } from "./layout/overlays/Overlays"; import { Modals } from "./modals/Modals"; import { AboutPage } from "./pages/AboutPage"; +import { BlockedUsers } from "./pages/account-settings/BlockedUsers"; import { FriendsPage } from "./pages/friends/FriendsPage"; const components: Record JSXElement> = { @@ -20,6 +21,7 @@ const components: Record JSXElement> = { modals: () => , overlays: () => , theme: () => , + accountblockedusers: () => , devtools: () => , }; 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..06bab54c6972 --- /dev/null +++ b/frontend/src/ts/components/pages/account-settings/BlockedUsers.tsx @@ -0,0 +1,82 @@ +import { Connection } from "@monkeytype/schemas/connections"; +import { and, eq, not, useLiveQuery } from "@tanstack/solid-db"; +import { createColumnHelper } from "@tanstack/solid-table"; +import { format } from "date-fns/format"; +import { createMemo, JSXElement, Show } from "solid-js"; + +import { connectionsCollection } from "../../../collections/connections"; +import { updateFriendRequestsIndicator } from "../../../elements/account-button"; +import * as Notifications from "../../../elements/notifications"; +import { getUserId } from "../../../signals/core"; +import { Button } from "../../common/Button"; +import { H3 } from "../../common/Headers"; +import { User } from "../../common/User"; +import { DataTable } from "../../ui/table/DataTable"; + +export function BlockedUsers(): JSXElement { + const query = useLiveQuery((q) => + q + .from({ connections: connectionsCollection }) + .where(({ connections }) => + and( + eq(connections.status, "blocked"), + not(eq(connections.initiatorUid, getUserId())), + ), + ), + ); + + const columns = createMemo(() => { + const defineColumn = createColumnHelper().accessor; + return [ + 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) { - Notifications.add(`Cannot unblock user: ${response.body.message}`, -1); - } 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]; - updateFriendRequestsIndicator(); - } - } -}); diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index c34f216d2928..bd455a9dc692 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 "../signals/loader-bar"; import * as ApeKeyTable from "../elements/account-settings/ape-key-table"; -import * as BlockedUserTable from "../elements/account-settings/blocked-user-table"; import * as Notifications from "../elements/notifications"; import { z } from "zod"; import * as AuthEvent from "../observables/auth-event"; @@ -153,7 +152,6 @@ export function updateUI(): void { updateIntegrationSections(); updateAccountSections(); void ApeKeyTable.update(updateUI); - void BlockedUserTable.update(); updateTabs(); page.setUrlParams(state); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7156d3862dff..b90cc74eb70b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,8 +292,8 @@ importers: specifier: 1.0.20 version: 1.0.20(@tanstack/query-core@5.90.20)(typescript@5.9.3) '@tanstack/solid-db': - specifier: 0.2.5 - version: 0.2.5(solid-js@1.9.10)(typescript@5.9.3) + specifier: 0.2.3 + version: 0.2.3(solid-js@1.9.10)(typescript@5.9.3) '@tanstack/solid-devtools': specifier: 0.7.23 version: 0.7.23(csstype@3.1.3)(solid-js@1.9.10) @@ -3442,11 +3442,6 @@ packages: peerDependencies: typescript: '>=4.7' - '@tanstack/db@0.5.25': - resolution: {integrity: sha512-VqVchs6Mm4rw2GyiOkaoD+PJw6lCJT8EI/TzPu8KWZy3QxyOlilpMvEuDTCl0LZdp1iLYlQT1NdgDg0gimV3kQ==} - peerDependencies: - typescript: '>=4.7' - '@tanstack/devtools-client@0.0.5': resolution: {integrity: sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA==} engines: {node: '>=18'} @@ -3487,8 +3482,8 @@ packages: '@tanstack/query-devtools@5.93.0': resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} - '@tanstack/solid-db@0.2.5': - resolution: {integrity: sha512-tunB6Nbd2UHnPGXwprc0S4OjsP1JvwG0blmLnKgoG3bKdGyJQ8yVknHc7WWwIUdeAz4tH4x/p2RDVloqEKrnCA==} + '@tanstack/solid-db@0.2.3': + resolution: {integrity: sha512-m0Y2qeKQOBYliXcxN3/oo2YC+cf87EPDA/he6O1oRWQh2Jtey05WZ9A1wuTCKRGk+KtE/fWhZN60kjg86cR25Q==} peerDependencies: solid-js: '>=1.9.0' @@ -13266,13 +13261,6 @@ snapshots: '@tanstack/pacer-lite': 0.2.1 typescript: 5.9.3 - '@tanstack/db@0.5.25(typescript@5.9.3)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@tanstack/db-ivm': 0.1.17(typescript@5.9.3) - '@tanstack/pacer-lite': 0.2.1 - typescript: 5.9.3 - '@tanstack/devtools-client@0.0.5': dependencies: '@tanstack/devtools-event-client': 0.4.0 @@ -13323,10 +13311,10 @@ snapshots: '@tanstack/query-devtools@5.93.0': {} - '@tanstack/solid-db@0.2.5(solid-js@1.9.10)(typescript@5.9.3)': + '@tanstack/solid-db@0.2.3(solid-js@1.9.10)(typescript@5.9.3)': dependencies: '@solid-primitives/map': 0.7.2(solid-js@1.9.10) - '@tanstack/db': 0.5.25(typescript@5.9.3) + '@tanstack/db': 0.5.23(typescript@5.9.3) solid-js: 1.9.10 transitivePeerDependencies: - typescript From ea965dbf1af2a0f931ab8209a63e23bade62f5dc Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 30 Jan 2026 17:04:37 +0100 Subject: [PATCH 22/26] remove from snapshot --- frontend/package.json | 1 - frontend/src/ts/collections/connections.ts | 91 +++- frontend/src/ts/components/DevTools.tsx | 7 +- .../pages/account-settings/BlockedUsers.tsx | 35 +- .../components/pages/friends/FriendsPage.tsx | 37 +- .../pages/friends/PendingConnectionsList.tsx | 16 +- frontend/src/ts/constants/default-snapshot.ts | 3 - frontend/src/ts/db.ts | 75 +-- frontend/src/ts/elements/account-button.ts | 18 +- frontend/src/ts/elements/profile.ts | 20 +- frontend/src/ts/firebase.ts | 3 +- frontend/src/ts/pages/friends.ts | 461 ------------------ frontend/src/ts/pages/leaderboards.ts | 5 +- frontend/src/ts/pages/profile.ts | 11 +- frontend/src/ts/signals/core.ts | 5 +- 15 files changed, 157 insertions(+), 631 deletions(-) delete mode 100644 frontend/src/ts/pages/friends.ts diff --git a/frontend/package.json b/frontend/package.json index 8368360109df..ca75942aa2cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,6 @@ "@tanstack/query-core": "5.90.20", "@tanstack/query-db-collection": "1.0.20", "@tanstack/solid-db": "0.2.3", - "@tanstack/solid-devtools": "0.7.23", "@tanstack/solid-query": "5.90.23", "@tanstack/solid-query-devtools": "5.91.3", "@tanstack/solid-table": "8.21.3", diff --git a/frontend/src/ts/collections/connections.ts b/frontend/src/ts/collections/connections.ts index 50d889f8ad96..c2e20b897269 100644 --- a/frontend/src/ts/collections/connections.ts +++ b/frontend/src/ts/collections/connections.ts @@ -1,28 +1,41 @@ -import { createCollection } from "@tanstack/db"; +import { + and, + createCollection, + eq, + InitialQueryBuilder, + not, +} from "@tanstack/db"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { queryClient } from "./client"; import Ape from "../ape"; +import { Connection } from "@monkeytype/schemas/connections"; +import { useLiveQuery } from "@tanstack/solid-db"; +import * as Notifications from "../elements/notifications"; import { createEffectOn } from "../hooks/effects"; -import { getActivePage, isLoggedIn } from "../signals/core"; -import { addToGlobal } from "../utils/misc"; +import { getActivePage, getUserId, isLoggedIn } from "../signals/core"; createEffectOn(getActivePage, (page) => { //refresh connections when entering the friends page if (page === "friends") { - void queryClient.invalidateQueries({ queryKey: ["connections"] }); + console.log("#### trigger refresh on page friends"); + invalidateQuery(); } }); +const connectionsCollectionName = "connections"; export const connectionsCollection = createCollection( queryCollectionOptions({ syncMode: "on-demand", queryClient, - queryKey: ["connections"], + queryKey: [connectionsCollectionName], + staleTime: 1000 * 60 * 5, + getKey: (item) => item._id, queryFn: async () => { if (!isLoggedIn()) return []; + console.log("### fetch connections"); const response = await Ape.connections.get(); if (response.status !== 200) { throw new Error("Error fetching connections:" + response.body.message); @@ -62,4 +75,70 @@ export const connectionsCollection = createCollection( }), ); -addToGlobal({ cc: connectionsCollection }); +export const pendingConnectionsQuery = useLiveQuery(() => ({ + id: "pendingConnections", + startSync: false, + query: (q: InitialQueryBuilder) => { + console.log("### pending"); + return q + .from({ connections: connectionsCollection }) + .where(({ connections }) => + and( + eq(connections.status, "pending"), + not(eq(connections.initiatorUid, getUserId())), + ), + ); + }, +})); + +export function isFriend(uid: string): boolean { + return ( + findConnectionByUid({ receiverUid: uid, initiatorUid: uid })?.status === + "accepted" + ); +} + +export function findConnectionByUid({ + initiatorUid, + receiverUid, +}: { + initiatorUid?: string; + receiverUid?: string; +}): Connection | undefined { + return connectionsCollection.toArray.find( + (it) => it.initiatorUid === initiatorUid || it.receiverUid === receiverUid, + ); +} + +export async function addConnection(receiverName: string): Promise { + const response = await Ape.connections.create({ body: { receiverName } }); + + if (response.status === 200) { + Notifications.add(`Request sent to ${receiverName}`, 1); + invalidateQuery(); + } else { + const result = response.body.message; + let status = -1; + let message = "Unknown error"; + + if (result.includes("already exists")) { + status = 0; + message = `You are already friends with ${receiverName}`; + } else if (result.includes("request already sent")) { + status = 0; + message = `You have already sent a friend request to ${receiverName}`; + } else if (result.includes("blocked by initiator")) { + status = 0; + message = `You have blocked ${receiverName}`; + } else if (result.includes("blocked by receiver")) { + status = 0; + message = `${receiverName} has blocked you`; + } + + Notifications.add(message, status); + } +} + +function invalidateQuery(): void { + // void queryClient.invalidateQueries({ queryKey: [connectionsCollectionName] }); +} diff --git a/frontend/src/ts/components/DevTools.tsx b/frontend/src/ts/components/DevTools.tsx index d8dae6fa31cb..ce32cc39bacf 100644 --- a/frontend/src/ts/components/DevTools.tsx +++ b/frontend/src/ts/components/DevTools.tsx @@ -1,11 +1,6 @@ -import { TanStackDevtools } from "@tanstack/solid-devtools"; import { SolidQueryDevtools } from "@tanstack/solid-query-devtools"; import { JSXElement } from "solid-js"; export function DevTools(): JSXElement { - return ( - }]} - /> - ); + return ; } diff --git a/frontend/src/ts/components/pages/account-settings/BlockedUsers.tsx b/frontend/src/ts/components/pages/account-settings/BlockedUsers.tsx index 06bab54c6972..64d8b1d012f1 100644 --- a/frontend/src/ts/components/pages/account-settings/BlockedUsers.tsx +++ b/frontend/src/ts/components/pages/account-settings/BlockedUsers.tsx @@ -1,5 +1,11 @@ import { Connection } from "@monkeytype/schemas/connections"; -import { and, eq, not, useLiveQuery } from "@tanstack/solid-db"; +import { + and, + eq, + InitialQueryBuilder, + not, + useLiveQuery, +} from "@tanstack/solid-db"; import { createColumnHelper } from "@tanstack/solid-table"; import { format } from "date-fns/format"; import { createMemo, JSXElement, Show } from "solid-js"; @@ -14,16 +20,21 @@ import { User } from "../../common/User"; import { DataTable } from "../../ui/table/DataTable"; export function BlockedUsers(): JSXElement { - const query = useLiveQuery((q) => - q - .from({ connections: connectionsCollection }) - .where(({ connections }) => - and( - eq(connections.status, "blocked"), - not(eq(connections.initiatorUid, getUserId())), - ), - ), - ); + const query = useLiveQuery(() => ({ + id: "blockedConnections", + startSync: false, + query: (q: InitialQueryBuilder) => { + console.log("### blocked"); + return q + .from({ connections: connectionsCollection }) + .where(({ connections }) => + and( + eq(connections.status, "blocked"), + not(eq(connections.initiatorUid, getUserId())), + ), + ); + }, + })); const columns = createMemo(() => { const defineColumn = createColumnHelper().accessor; @@ -60,7 +71,7 @@ export function BlockedUsers(): JSXElement { return ( <>
-

+

Blocked users cannot send you friend requests.

diff --git a/frontend/src/ts/components/pages/friends/FriendsPage.tsx b/frontend/src/ts/components/pages/friends/FriendsPage.tsx index 6061f80bed62..284498bd0601 100644 --- a/frontend/src/ts/components/pages/friends/FriendsPage.tsx +++ b/frontend/src/ts/components/pages/friends/FriendsPage.tsx @@ -5,7 +5,11 @@ import { JSXElement, Show } from "solid-js"; import Ape from "../../../ape"; import { queryClient } from "../../../collections/client"; -import { connectionsCollection } from "../../../collections/connections"; +import { + addConnection, + connectionsCollection, +} from "../../../collections/connections"; +import { updateFriendRequestsIndicator } from "../../../elements/account-button"; import * as Notifications from "../../../elements/notifications"; import { getActivePage, getUserId } from "../../../signals/core"; import { addToGlobal } from "../../../utils/misc"; @@ -21,6 +25,7 @@ export function FriendsPage(): JSXElement { return { queryClient: queryClient, queryKey: [friendsDataName], + staleTime: 1000 * 60 * 5, //cache for 5 minutes queryFn: async () => { const response = await Ape.users.getFriends(); if (response.status !== 200) { @@ -35,33 +40,7 @@ export function FriendsPage(): JSXElement { addToGlobal({ fd: friendsData }); const addFriend = async (receiverName: string): Promise => { - setTimeout(async () => { - const response = await Ape.connections.create({ body: { receiverName } }); - - if (response.status === 200) { - Notifications.add(`Request sent to ${receiverName}`, 1); - } else { - const result = response.body.message; - let status = -1; - let message = "Unknown error"; - - if (result.includes("already exists")) { - status = 0; - message = `You are already friends with ${receiverName}`; - } else if (result.includes("request already sent")) { - status = 0; - message = `You have already sent a friend request to ${receiverName}`; - } else if (result.includes("blocked by initiator")) { - status = 0; - message = `You have blocked ${receiverName}`; - } else if (result.includes("blocked by receiver")) { - status = 0; - message = `${receiverName} has blocked you`; - } - - Notifications.add(message, status); - } - }, 0); + void addConnection(receiverName); }; const removeFriend = async (connectionId: string): Promise => { @@ -108,6 +87,8 @@ export function FriendsPage(): JSXElement { if (status === "rejected") { Notifications.add(`Request rejected`, 0); } + + updateFriendRequestsIndicator(); }); }; diff --git a/frontend/src/ts/components/pages/friends/PendingConnectionsList.tsx b/frontend/src/ts/components/pages/friends/PendingConnectionsList.tsx index 1ccf6ea9279e..c03bf089832d 100644 --- a/frontend/src/ts/components/pages/friends/PendingConnectionsList.tsx +++ b/frontend/src/ts/components/pages/friends/PendingConnectionsList.tsx @@ -1,11 +1,9 @@ import { Connection, ConnectionStatus } from "@monkeytype/schemas/connections"; -import { and, eq, not, useLiveQuery } from "@tanstack/solid-db"; import { createColumnHelper } from "@tanstack/solid-table"; import { format as dateFormat } from "date-fns/format"; import { createMemo, JSXElement, Show } from "solid-js"; -import { connectionsCollection } from "../../../collections/connections"; -import { getUserId } from "../../../signals/core"; +import { pendingConnectionsQuery } from "../../../collections/connections"; import { formatAge } from "../../../utils/date-and-time"; import { Button } from "../../common/Button"; import { H2 } from "../../common/Headers"; @@ -24,17 +22,7 @@ export function PendingConnectionsList(props: { const columns = createMemo(() => getColumnDefinitions({ onUpdate: props.onUpdate }), ); - const query = useLiveQuery((q) => - q - .from({ connections: connectionsCollection }) - .where(({ connections }) => - and( - eq(connections.status, "pending"), - not(eq(connections.initiatorUid, getUserId())), - ), - ), - ); - + const query = pendingConnectionsQuery; return ( 0}>

; }; export type SnapshotPreset = Preset & { @@ -133,7 +131,6 @@ const defaultSnap = { 60: { english: { count: 0, rank: 0 } }, }, }, - connections: {}, } as Snapshot; export function getDefaultSnapshot(): Snapshot { diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 0e8d67363e0a..1fb8ba8fff9b 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -1,6 +1,6 @@ import Ape from "./ape"; import * as Notifications from "./elements/notifications"; -import { isAuthenticated, getAuthenticatedUser } from "./firebase"; +import { isAuthenticated } from "./firebase"; import * as ConnectionState from "./states/connection"; import { lastElementFromArray } from "./utils/arrays"; import { migrateConfig } from "./utils/config"; @@ -30,11 +30,7 @@ import { FunboxMetadata } from "../../../packages/funbox/src/types"; import { getFirstDayOfTheWeek } from "./utils/date-and-time"; import { Language } from "@monkeytype/schemas/languages"; import * as AuthEvent from "./observables/auth-event"; -import { - configurationPromise, - get as getServerConfiguration, -} from "./ape/server-configuration"; -import { Connection } from "@monkeytype/schemas/connections"; +import { configurationPromise } from "./ape/server-configuration"; let dbSnapshot: Snapshot | undefined; const firstDayOfTheWeek = getFirstDayOfTheWeek(); @@ -94,17 +90,11 @@ export async function initSnapshot(): Promise { try { if (!isAuthenticated()) return false; - const connectionsRequest = getServerConfiguration()?.connections.enabled - ? Ape.connections.get() - : { status: 200, body: { message: "", data: [] } }; - - const [userResponse, configResponse, presetsResponse, connectionsResponse] = - await Promise.all([ - Ape.users.get(), - Ape.configs.get(), - Ape.presets.get(), - connectionsRequest, - ]); + const [userResponse, configResponse, presetsResponse] = await Promise.all([ + Ape.users.get(), + Ape.configs.get(), + Ape.presets.get(), + ]); if (userResponse.status !== 200) { throw new SnapshotInitError( @@ -124,17 +114,10 @@ export async function initSnapshot(): Promise { presetsResponse.status, ); } - if (connectionsResponse.status !== 200) { - throw new SnapshotInitError( - `${connectionsResponse.body.message} (connections)`, - connectionsResponse.status, - ); - } const userData = userResponse.body.data; const configData = configResponse.body.data; const presetsData = presetsResponse.body.data; - const connectionsData = connectionsResponse.body.data; if (userData === null) { throw new SnapshotInitError( @@ -270,8 +253,6 @@ export async function initSnapshot(): Promise { ); } - snap.connections = convertConnections(connectionsData); - dbSnapshot = snap; return dbSnapshot; } catch (e) { @@ -1139,48 +1120,6 @@ export async function getTestActivityCalendar( return dbSnapshot.testActivityByYear[yearString]; } -export function mergeConnections(connections: Connection[]): void { - const snapshot = getSnapshot(); - if (!snapshot) return; - - const update = convertConnections(connections); - - for (const [key, value] of Object.entries(update)) { - snapshot.connections[key] = value; - } - - setSnapshot(snapshot); -} - -function convertConnections( - connectionsData: Connection[], -): Snapshot["connections"] { - return Object.fromEntries( - connectionsData.map((connection) => { - const isMyRequest = - getAuthenticatedUser()?.uid === connection.initiatorUid; - - return [ - isMyRequest ? connection.receiverUid : connection.initiatorUid, - connection.status === "pending" && !isMyRequest - ? "incoming" - : connection.status, - ]; - }), - ); -} - -export function isFriend(uid: string | undefined): boolean { - if (uid === undefined || uid === getAuthenticatedUser()?.uid) return false; - - const snapshot = getSnapshot(); - if (!snapshot) return false; - - return Object.entries(snapshot.connections).some( - ([receiverUid, status]) => receiverUid === uid && status === "accepted", - ); -} - // export async function DB.getLocalTagPB(tagId) { // function cont() { // let ret = 0; diff --git a/frontend/src/ts/elements/account-button.ts b/frontend/src/ts/elements/account-button.ts index 5d394ce75908..cbe487c76e9a 100644 --- a/frontend/src/ts/elements/account-button.ts +++ b/frontend/src/ts/elements/account-button.ts @@ -9,6 +9,7 @@ import { getAvatarElement } from "../utils/discord-avatar"; import * as AuthEvent from "../observables/auth-event"; import { getSnapshot } from "../db"; import { qsr } from "../utils/dom"; +import { pendingConnectionsQuery } from "../collections/connections"; const nav = qsr("header nav"); const accountButtonAndMenuEl = nav.qsr(".accountButtonAndMenu"); @@ -84,22 +85,15 @@ export function update(): void { } export function updateFriendRequestsIndicator(): void { - const friends = getSnapshot()?.connections; - const bubbleElements = accountButtonAndMenuEl.qsa( ".view-account > .notificationBubble, .goToFriends > .notificationBubble", ); - if (friends !== undefined) { - const pendingFriendRequests = Object.values(friends).filter( - (it) => it === "incoming", - ).length; - if (pendingFriendRequests > 0) { - bubbleElements.show(); - return; - } - } - bubbleElements.hide(); + if (pendingConnectionsQuery().length > 0) { + bubbleElements.show(); + } else { + bubbleElements.hide(); + } } const coarse = window.matchMedia("(pointer:coarse)")?.matches; diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index d45378c2a0c0..8169b2e240bb 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -21,6 +21,7 @@ import { formatXp } from "../utils/levels"; import { formatTopPercentage } from "../utils/misc"; import { get as getServerConfiguration } from "../ape/server-configuration"; import { qs } from "../utils/dom"; +import { findConnectionByUid, isFriend } from "../collections/connections"; type ProfileViewPaths = "profile" | "account"; type UserProfileOrSnapshot = UserProfile | Snapshot; @@ -73,11 +74,12 @@ export async function update( } details?.qs(".name")?.setText(profile.name); - details - ?.qs(".userFlags") - ?.setHtml( - getHtmlByUserFlags({ ...profile, isFriend: DB.isFriend(profile.uid) }), - ); + details?.qs(".userFlags")?.setHtml( + getHtmlByUserFlags({ + ...profile, + isFriend: profile.uid !== undefined && isFriend(profile.uid), + }), + ); if (profile.lbOptOut === true) { if (where === "profile") { @@ -442,7 +444,7 @@ export function updateNameFontSize(where: ProfileViewPaths): void { nameField.native.style.fontSize = `${finalFontSize}px`; } -export function updateFriendRequestButton(): void { +function updateFriendRequestButton(): void { const myUid = getAuthenticatedUser()?.uid; const profileUid = document .querySelector(".profile") @@ -450,7 +452,11 @@ export function updateFriendRequestButton(): void { const button = document.querySelector(".profile .addFriendButton"); const myProfile = myUid === profileUid; - const hasRequest = DB.getSnapshot()?.connections[profileUid] !== undefined; + const existingConnection = findConnectionByUid({ + initiatorUid: profileUid, + receiverUid: profileUid, + }); + const hasRequest = existingConnection !== undefined; const featureEnabled = getServerConfiguration()?.connections.enabled; if (!featureEnabled || myUid === undefined || myProfile) { diff --git a/frontend/src/ts/firebase.ts b/frontend/src/ts/firebase.ts index c12620e4d5ab..26b8d6a7e6fc 100644 --- a/frontend/src/ts/firebase.ts +++ b/frontend/src/ts/firebase.ts @@ -35,7 +35,7 @@ import { import { tryCatch } from "@monkeytype/util/trycatch"; import { dispatch as dispatchSignUpEvent } from "./observables/google-sign-up-event"; import { addBanner } from "./stores/banners"; -import { setUserId } from "./signals/core"; +import { getUserId, setUserId } from "./signals/core"; let app: FirebaseApp | undefined; let Auth: AuthType | undefined; @@ -76,6 +76,7 @@ export async function init(callback: ReadyCallback): Promise { onAuthStateChanged(Auth, async (user) => { if (!ignoreAuthCallback) { + console.log("###", user?.uid, getUserId()); setUserId(user?.uid ?? null); await callback(true, user); } diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts deleted file mode 100644 index b7da0f03f323..000000000000 --- a/frontend/src/ts/pages/friends.ts +++ /dev/null @@ -1,461 +0,0 @@ -import Page from "./page"; -import * as Skeleton from "../utils/skeleton"; -import Ape from "../ape"; -import { format as dateFormat, format } from "date-fns"; -import * as Notifications from "../elements/notifications"; -import { isSafeNumber } from "@monkeytype/util/numbers"; -import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller"; -import { formatXp, getXpDetails } from "../utils/levels"; -import { formatAge, secondsToString } from "../utils/date-and-time"; -import { PersonalBest } from "@monkeytype/schemas/shared"; -import Format from "../utils/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 { getAuthenticatedUser } from "../firebase"; -import * as ServerConfiguration from "../ape/server-configuration"; -import * as AuthEvent from "../observables/auth-event"; -import { Connection } from "@monkeytype/schemas/connections"; -import { Friend } from "@monkeytype/schemas/users"; - -import { showLoaderBar, hideLoaderBar } from "../signals/loader-bar"; -import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; -import { qs, qsr } from "../utils/dom"; - -let friendsTable: SortedTable | undefined = undefined; - -let pendingRequests: Connection[] | undefined; -let friendsList: Friend[] | undefined; - -export function getReceiverUid( - connection: Pick, -): string { - const me = getAuthenticatedUser(); - if (me === null) { - throw new Error("expected to be authenticated in getReceiverUid"); - } - - if (me.uid === connection.initiatorUid) return connection.receiverUid; - return connection.initiatorUid; -} - -export async function addFriend(receiverName: string): Promise { - const result = await Ape.connections.create({ body: { receiverName } }); - - if (result.status !== 200) { - return `Friend request failed: ${result.body.message}`; - } else { - const snapshot = DB.getSnapshot(); - if (snapshot !== undefined) { - const receiverUid = getReceiverUid(result.body.data); - // oxlint-disable-next-line no-unsafe-member-access - snapshot.connections[receiverUid] = result.body.data.status; - updatePendingConnections(); - } - return true; - } -} -async function fetchPendingConnections(): Promise { - const result = await Ape.connections.get({ - query: { status: "pending", type: "incoming" }, - }); - - if (result.status !== 200) { - Notifications.add("Error getting connections: " + result.body.message, -1); - 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) { - Notifications.add("Error getting friends: " + result.body.message, -1); - 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 = `