diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06fe2e77..b174be51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,45 +1,34 @@ name: CI on: - - push - - pull_request + - push: + branches: + - "**" + - pull_request: + branches: + - main jobs: - cache-and-build: - runs-on: ubuntu-latest + build: + uses: ./.github/workflows/setup.yml + with: + node-version: 20 + pnpm-version: 9.5.0 steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - uses: pnpm/action-setup@v3 - name: Install pnpm - with: - version: 9.4.0 - run_install: false - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install - - name: Build project env: TURSO_CONNECTION_URL: ${{ secrets.TURSO_CONNECTION_URL }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: pnpm build + + graphql-diagnostics: + need: build + uses: ./.github/workflows/setup.yml + with: + node-version: 20 + pnpm-version: 9.5.0 + + steps: + - name: Run GraphQL diagnostics + run: pnpm gql:check diff --git a/.github/workflows/setup.yml b/.github/workflows/setup.yml new file mode 100644 index 00000000..752760d4 --- /dev/null +++ b/.github/workflows/setup.yml @@ -0,0 +1,44 @@ +name: Setup + +on: + workflow_call: + inputs: + node-version: + required: true + type: string + pnpm-version: + required: true + type: string + +jobs: + setup: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: ${{ inputs.pnpm-version }} + run_install: false + + - name: Get pnpm store directory + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..f0eb61e0 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": false +} diff --git a/apps/www/.gitignore b/apps/www/.gitignore index fd3dbb57..1dd45b20 100644 --- a/apps/www/.gitignore +++ b/apps/www/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index 05656a82..d0e234ad 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -1,3 +1,4 @@ +import { withSentryConfig } from "@sentry/nextjs"; import remarkGfm from "remark-gfm"; import createMDX from "@next/mdx"; @@ -5,7 +6,7 @@ import createMDX from "@next/mdx"; const nextConfig = { pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"], experimental: { - serverComponentsExternalPackages: ["@node-rs/argon2"], + serverComponentsExternalPackages: ["@node-rs/argon2", "@sentry/nextjs"], }, compiler: { removeConsole: process.env.NODE_ENV === "production", @@ -28,4 +29,38 @@ const withMDX = createMDX({ }, }); -export default withMDX(nextConfig); +export default withSentryConfig(withMDX(nextConfig), { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + org: "omsimos", + project: "umamin", + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + // tunnelRoute: "/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, +}); + diff --git a/apps/www/package.json b/apps/www/package.json index a78e02ff..19cb4053 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -8,20 +8,24 @@ "start": "next start", "clean": "rm -rf ./node_modules .turbo .next", "check-types": "tsc --noEmit && gql.tada check", - "lint": "next lint" + "lint": "next lint", + "gql:check": "gql.tada check", + "gql:generate-persisted": "gql.tada generate-persisted", + "gql:generate-schema": "gql.tada generate-schema http://localhost:3000/api/graphql" }, "dependencies": { "@fingerprintjs/botd": "^1.9.1", - "@graphql-yoga/plugin-apq": "^3.4.0", - "@graphql-yoga/plugin-csrf-prevention": "^3.4.0", - "@graphql-yoga/plugin-disable-introspection": "^2.4.0", + "@graphql-yoga/plugin-csrf-prevention": "^3.6.0", + "@graphql-yoga/plugin-disable-introspection": "^2.6.0", + "@graphql-yoga/plugin-persisted-operations": "^3.6.0", "@graphql-yoga/plugin-response-cache": "^3.8.0", "@hookform/resolvers": "^3.3.4", "@lucia-auth/adapter-drizzle": "^1.0.7", "@mdx-js/loader": "^3.0.1", "@mdx-js/react": "^3.0.1", - "@next/mdx": "^14.2.4", + "@next/mdx": "^14.2.5", "@node-rs/argon2": "^1.8.3", + "@sentry/nextjs": "^8", "@types/mdx": "^2.0.13", "@umamin/aes": "workspace:*", "@umamin/db": "workspace:*", @@ -29,6 +33,7 @@ "@umamin/ui": "workspace:*", "@urql/core": "^5.0.4", "@urql/exchange-graphcache": "^7.1.1", + "@urql/exchange-persisted": "^4.3.0", "@urql/next": "^1.1.1", "arctic": "^1.9.1", "class-variance-authority": "^0.7.0", @@ -36,14 +41,14 @@ "date-fns": "^3.6.0", "firebase": "^10.12.1", "geist": "^1.3.0", - "gql.tada": "^1.8.0", - "graphql": "^16.8.1", - "graphql-yoga": "^5.4.0", + "gql.tada": "^1.8.2", + "graphql": "^16.9.0", + "graphql-yoga": "^5.6.0", "lucia": "^3.2.0", - "lucide-react": "^0.358.0", + "lucide-react": "^0.407.0", "modern-screenshot": "^4.4.39", "nanoid": "^5.0.7", - "next": "14.2.4", + "next": "14.2.5", "next-themes": "^0.3.0", "nextjs-toploader": "^1.6.12", "oslo": "^1.2.1", @@ -60,7 +65,7 @@ "zustand": "^4.5.4" }, "devDependencies": { - "@0no-co/graphqlsp": "^1.12.9", + "@0no-co/graphqlsp": "^1.12.11", "@types/node": "^20", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", @@ -68,7 +73,7 @@ "@umamin/tsconfig": "workspace:*", "autoprefixer": "^10.0.1", "eslint": "^8", - "eslint-config-next": "14.1.3", + "eslint-config-next": "14.2.5", "postcss": "^8", "tailwindcss": "^3.3.0", "typescript": "^5.4.5" diff --git a/apps/www/sentry.client.config.ts b/apps/www/sentry.client.config.ts new file mode 100644 index 00000000..ace8a931 --- /dev/null +++ b/apps/www/sentry.client.config.ts @@ -0,0 +1,31 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + enabled: process.env.NODE_ENV === "production", + dsn: "https://91072fe34a5a53274d5c2372c9a85120@o4507604730183680.ingest.us.sentry.io/4507604736802816", + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + replaysOnErrorSampleRate: 1.0, + + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + integrations: [ + Sentry.replayIntegration({ + // Additional Replay configuration goes in here, for example: + maskAllText: true, + blockAllMedia: true, + }), + ], +}); diff --git a/apps/www/sentry.edge.config.ts b/apps/www/sentry.edge.config.ts new file mode 100644 index 00000000..d005a05f --- /dev/null +++ b/apps/www/sentry.edge.config.ts @@ -0,0 +1,17 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + enabled: process.env.NODE_ENV === "production", + dsn: "https://91072fe34a5a53274d5c2372c9a85120@o4507604730183680.ingest.us.sentry.io/4507604736802816", + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/apps/www/sentry.server.config.ts b/apps/www/sentry.server.config.ts new file mode 100644 index 00000000..56916c95 --- /dev/null +++ b/apps/www/sentry.server.config.ts @@ -0,0 +1,19 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + enabled: process.env.NODE_ENV === "production", + dsn: "https://91072fe34a5a53274d5c2372c9a85120@o4507604730183680.ingest.us.sentry.io/4507604736802816", + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + // Uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: process.env.NODE_ENV === 'development', +}); diff --git a/apps/www/src/app/login/components/form.tsx b/apps/www/src/app/(authentication)/login/components/form.tsx similarity index 100% rename from apps/www/src/app/login/components/form.tsx rename to apps/www/src/app/(authentication)/login/components/form.tsx diff --git a/apps/www/src/app/login/components/login-button.tsx b/apps/www/src/app/(authentication)/login/components/login-button.tsx similarity index 100% rename from apps/www/src/app/login/components/login-button.tsx rename to apps/www/src/app/(authentication)/login/components/login-button.tsx diff --git a/apps/www/src/app/login/google/callback/route.ts b/apps/www/src/app/(authentication)/login/google/callback/route.ts similarity index 100% rename from apps/www/src/app/login/google/callback/route.ts rename to apps/www/src/app/(authentication)/login/google/callback/route.ts diff --git a/apps/www/src/app/login/google/route.ts b/apps/www/src/app/(authentication)/login/google/route.ts similarity index 100% rename from apps/www/src/app/login/google/route.ts rename to apps/www/src/app/(authentication)/login/google/route.ts diff --git a/apps/www/src/app/login/loading.tsx b/apps/www/src/app/(authentication)/login/loading.tsx similarity index 100% rename from apps/www/src/app/login/loading.tsx rename to apps/www/src/app/(authentication)/login/loading.tsx diff --git a/apps/www/src/app/login/page.tsx b/apps/www/src/app/(authentication)/login/page.tsx similarity index 90% rename from apps/www/src/app/login/page.tsx rename to apps/www/src/app/(authentication)/login/page.tsx index 0704af59..6e90ec90 100644 --- a/apps/www/src/app/login/page.tsx +++ b/apps/www/src/app/(authentication)/login/page.tsx @@ -1,9 +1,13 @@ import Link from "next/link"; +import dynamic from "next/dynamic"; import { getSession } from "@/lib/auth"; import { redirect } from "next/navigation"; import { LoginForm } from "./components/form"; -import { V1Link } from "../components/v1-link"; -import { BrowserWarning } from "@umamin/ui/components/browser-warning"; +import { V1Link } from "@/app/components/v1-link"; + +const BrowserWarning = dynamic( + () => import("@umamin/ui/components/browser-warning"), +); export const metadata = { title: "Umamin — Login", diff --git a/apps/www/src/app/register/components/button.tsx b/apps/www/src/app/(authentication)/register/components/button.tsx similarity index 100% rename from apps/www/src/app/register/components/button.tsx rename to apps/www/src/app/(authentication)/register/components/button.tsx diff --git a/apps/www/src/app/register/components/form.tsx b/apps/www/src/app/(authentication)/register/components/form.tsx similarity index 100% rename from apps/www/src/app/register/components/form.tsx rename to apps/www/src/app/(authentication)/register/components/form.tsx diff --git a/apps/www/src/app/inbox/loading.tsx b/apps/www/src/app/(authentication)/register/loading.tsx similarity index 100% rename from apps/www/src/app/inbox/loading.tsx rename to apps/www/src/app/(authentication)/register/loading.tsx diff --git a/apps/www/src/app/register/page.tsx b/apps/www/src/app/(authentication)/register/page.tsx similarity index 92% rename from apps/www/src/app/register/page.tsx rename to apps/www/src/app/(authentication)/register/page.tsx index 6b413565..bcd2e543 100644 --- a/apps/www/src/app/register/page.tsx +++ b/apps/www/src/app/(authentication)/register/page.tsx @@ -1,9 +1,13 @@ import Link from "next/link"; +import dynamic from "next/dynamic"; import { getSession } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { V1Link } from "../components/v1-link"; import { RegisterForm } from "./components/form"; -import { BrowserWarning } from "@umamin/ui/components/browser-warning"; +import { V1Link } from "@/app/components/v1-link"; + +const BrowserWarning = dynamic( + () => import("@umamin/ui/components/browser-warning"), +); export const metadata = { title: "Umamin — Register", diff --git a/apps/www/src/app/privacy/layout.tsx b/apps/www/src/app/(markdown)/privacy/layout.tsx similarity index 100% rename from apps/www/src/app/privacy/layout.tsx rename to apps/www/src/app/(markdown)/privacy/layout.tsx diff --git a/apps/www/src/app/privacy/page.mdx b/apps/www/src/app/(markdown)/privacy/page.mdx similarity index 100% rename from apps/www/src/app/privacy/page.mdx rename to apps/www/src/app/(markdown)/privacy/page.mdx diff --git a/apps/www/src/app/terms/layout.tsx b/apps/www/src/app/(markdown)/terms/layout.tsx similarity index 100% rename from apps/www/src/app/terms/layout.tsx rename to apps/www/src/app/(markdown)/terms/layout.tsx diff --git a/apps/www/src/app/terms/page.mdx b/apps/www/src/app/(markdown)/terms/page.mdx similarity index 100% rename from apps/www/src/app/terms/page.mdx rename to apps/www/src/app/(markdown)/terms/page.mdx diff --git a/apps/www/src/app/inbox/components/received/card.tsx b/apps/www/src/app/(profile)/inbox/components/received/card.tsx similarity index 100% rename from apps/www/src/app/inbox/components/received/card.tsx rename to apps/www/src/app/(profile)/inbox/components/received/card.tsx diff --git a/apps/www/src/app/inbox/components/received/list.tsx b/apps/www/src/app/(profile)/inbox/components/received/list.tsx similarity index 70% rename from apps/www/src/app/inbox/components/received/list.tsx rename to apps/www/src/app/(profile)/inbox/components/received/list.tsx index 704053bf..c9774a28 100644 --- a/apps/www/src/app/inbox/components/received/list.tsx +++ b/apps/www/src/app/(profile)/inbox/components/received/list.tsx @@ -1,22 +1,20 @@ "use client"; import { toast } from "sonner"; -import dynamic from "next/dynamic"; import { graphql } from "gql.tada"; import { useInView } from "react-intersection-observer"; import { useCallback, useEffect, useState } from "react"; import client from "@/lib/gql/client"; -import { ReceivedMessagesResult } from "../../queries"; +import { formatError } from "@/lib/utils"; +import type { InboxProps } from "../../queries"; import { Skeleton } from "@umamin/ui/components/skeleton"; import { useMessageStore } from "@/store/useMessageStore"; import { ReceivedMessageCard, receivedMessageFragment } from "./card"; -const AdContainer = dynamic(() => import("@umamin/ui/ad"), { ssr: false }); - const MESSAGES_FROM_CURSOR_QUERY = graphql( ` - query MessagesFromCursor($input: MessagesFromCursorInput!) { + query ReceivedMessagesFromCursor($input: MessagesFromCursorInput!) { messagesFromCursor(input: $input) { __typename data { @@ -34,20 +32,20 @@ const MESSAGES_FROM_CURSOR_QUERY = graphql( } } `, - [receivedMessageFragment], + [receivedMessageFragment] +); + +const messagesFromCursorPersisted = graphql.persisted( + "10ae521c718fee919520bf95d2cdc74ee1bd0d862d468ca4948ad705bb1e2909", + MESSAGES_FROM_CURSOR_QUERY ); export function ReceivedMessagesList({ messages, -}: { - messages: ReceivedMessagesResult; -}) { + initialCursor, +}: InboxProps<"received">) { const { ref, inView } = useInView(); - - const [cursor, setCursor] = useState({ - id: messages[messages.length - 1]?.id ?? null, - createdAt: messages[messages.length - 1]?.createdAt ?? null, - }); + const [cursor, setCursor] = useState(initialCursor); const msgList = useMessageStore((state) => state.receivedList); const updateMsgList = useMessageStore((state) => state.updateReceivedList); @@ -59,7 +57,7 @@ export function ReceivedMessagesList({ if (hasMore) { setIsFetching(true); - const res = await client.query(MESSAGES_FROM_CURSOR_QUERY, { + const res = await client.query(messagesFromCursorPersisted, { input: { type: "received", cursor, @@ -67,7 +65,7 @@ export function ReceivedMessagesList({ }); if (res.error) { - toast.error(res.error.message); + toast.error(formatError(res.error.message)); return; } @@ -98,25 +96,15 @@ export function ReceivedMessagesList({ return ( <> - {messages?.map((msg, i) => ( + {messages?.map((msg) => (
- - {/* v2-received-list */} - {(i + 1) % 5 === 0 && ( - - )}
))} - {msgList?.map((msg, i) => ( + {msgList?.map((msg) => (
- - {/* v2-received-list */} - {(i + 1) % 5 === 0 && ( - - )}
))} diff --git a/apps/www/src/app/inbox/components/received/menu.tsx b/apps/www/src/app/(profile)/inbox/components/received/menu.tsx similarity index 92% rename from apps/www/src/app/inbox/components/received/menu.tsx rename to apps/www/src/app/(profile)/inbox/components/received/menu.tsx index bc04190e..53ef0f33 100644 --- a/apps/www/src/app/inbox/components/received/menu.tsx +++ b/apps/www/src/app/(profile)/inbox/components/received/menu.tsx @@ -29,6 +29,11 @@ const DELETE_MESSAGE_MUTATION = graphql(` } `); +const deleteMessagePersisted = graphql.persisted( + "402dc5134e5ce477b966ad52be60a7ce75ca058a46a6f024bf34074a878a5a7d", + DELETE_MESSAGE_MUTATION +); + export type ReceivedMenuProps = { id: string; question: string; @@ -44,7 +49,7 @@ export function ReceivedMessageMenu(props: ReceivedMenuProps) { const [open, setOpen] = useState(false); const onDelete = async () => { - const res = await client.mutation(DELETE_MESSAGE_MUTATION, { id }); + const res = await client.mutation(deleteMessagePersisted, { id }); if (res.error) { toast.error(formatError(res.error.message)); diff --git a/apps/www/src/app/(profile)/inbox/components/received/messages.tsx b/apps/www/src/app/(profile)/inbox/components/received/messages.tsx new file mode 100644 index 00000000..32fa7d69 --- /dev/null +++ b/apps/www/src/app/(profile)/inbox/components/received/messages.tsx @@ -0,0 +1,24 @@ +import { ReceivedMessagesList } from "./list"; +import { getReceivedMessages } from "../../queries"; + +export async function ReceivedMessages({ sessionId }: { sessionId?: string }) { + const messages = await getReceivedMessages(sessionId); + + return ( +
+ {!messages?.length ? ( +

+ No messages to show +

+ ) : ( + + )} +
+ ); +} diff --git a/apps/www/src/app/inbox/components/received/reply.tsx b/apps/www/src/app/(profile)/inbox/components/received/reply.tsx similarity index 95% rename from apps/www/src/app/inbox/components/received/reply.tsx rename to apps/www/src/app/(profile)/inbox/components/received/reply.tsx index c4132ebd..e5e4cc6e 100644 --- a/apps/www/src/app/inbox/components/received/reply.tsx +++ b/apps/www/src/app/(profile)/inbox/components/received/reply.tsx @@ -31,6 +31,11 @@ const CREATE_REPLY_MUTATION = graphql(` } `); +const createReplyPeresisted = graphql.persisted( + "a9f43ea2b50cd25c32cadd6fd3db25e06ed0919f12705455856d59a48dd572cd", + CREATE_REPLY_MUTATION +); + export function ReplyDialog(props: Props) { const router = useRouter(); const [content, setContent] = useState(""); @@ -43,7 +48,7 @@ export function ReplyDialog(props: Props) { e.preventDefault(); setLoading(true); - const res = await client.mutation(CREATE_REPLY_MUTATION, { + const res = await client.mutation(createReplyPeresisted, { messageId: props.data.id, content, }); diff --git a/apps/www/src/app/inbox/components/sent/card.tsx b/apps/www/src/app/(profile)/inbox/components/sent/card.tsx similarity index 100% rename from apps/www/src/app/inbox/components/sent/card.tsx rename to apps/www/src/app/(profile)/inbox/components/sent/card.tsx diff --git a/apps/www/src/app/inbox/components/sent/list.tsx b/apps/www/src/app/(profile)/inbox/components/sent/list.tsx similarity index 70% rename from apps/www/src/app/inbox/components/sent/list.tsx rename to apps/www/src/app/(profile)/inbox/components/sent/list.tsx index 9e3ad613..47a8f5e0 100644 --- a/apps/www/src/app/inbox/components/sent/list.tsx +++ b/apps/www/src/app/(profile)/inbox/components/sent/list.tsx @@ -1,22 +1,20 @@ "use client"; import { toast } from "sonner"; -import dynamic from "next/dynamic"; import { graphql } from "gql.tada"; import { useInView } from "react-intersection-observer"; import { useCallback, useEffect, useState } from "react"; import client from "@/lib/gql/client"; -import { SentMessageResult } from "../../queries"; +import { formatError } from "@/lib/utils"; +import type { InboxProps } from "../../queries"; import { Skeleton } from "@umamin/ui/components/skeleton"; import { useMessageStore } from "@/store/useMessageStore"; import { sentMessageFragment, SentMessageCard } from "./card"; -const AdContainer = dynamic(() => import("@umamin/ui/ad"), { ssr: false }); - const MESSAGES_FROM_CURSOR_QUERY = graphql( ` - query MessagesFromCursor($input: MessagesFromCursorInput!) { + query SentMessagesFromCursor($input: MessagesFromCursorInput!) { messagesFromCursor(input: $input) { __typename data { @@ -34,20 +32,20 @@ const MESSAGES_FROM_CURSOR_QUERY = graphql( } } `, - [sentMessageFragment], + [sentMessageFragment] +); + +const messagesFromCursorPersisted = graphql.persisted( + "9ceb104c0e9991769bf9544b2f7d605f0c66bfcfc488c0ae3dfaa7a975001b30", + MESSAGES_FROM_CURSOR_QUERY ); export function SentMessagesList({ messages, -}: { - messages: SentMessageResult; -}) { + initialCursor, +}: InboxProps<"sent">) { const { ref, inView } = useInView(); - - const [cursor, setCursor] = useState({ - id: messages[messages.length - 1]?.id ?? null, - createdAt: messages[messages.length - 1]?.createdAt ?? null, - }); + const [cursor, setCursor] = useState(initialCursor); const msgList = useMessageStore((state) => state.sentList); const updateMsgList = useMessageStore((state) => state.updateSentList); @@ -59,7 +57,7 @@ export function SentMessagesList({ if (hasMore) { setIsFetching(true); - const res = await client.query(MESSAGES_FROM_CURSOR_QUERY, { + const res = await client.query(messagesFromCursorPersisted, { input: { type: "sent", cursor, @@ -67,7 +65,7 @@ export function SentMessagesList({ }); if (res.error) { - toast.error(res.error.message); + toast.error(formatError(res.error.message)); return; } @@ -98,25 +96,15 @@ export function SentMessagesList({ return ( <> - {messages?.map((msg, i) => ( + {messages?.map((msg) => (
- - {/* v2-sent-list */} - {(i + 1) % 5 === 0 && ( - - )}
))} - {msgList?.map((msg, i) => ( + {msgList?.map((msg) => (
- - {/* v2-sent-list */} - {(i + 1) % 5 === 0 && ( - - )}
))} diff --git a/apps/www/src/app/(profile)/inbox/components/sent/messages.tsx b/apps/www/src/app/(profile)/inbox/components/sent/messages.tsx new file mode 100644 index 00000000..5a3ab90f --- /dev/null +++ b/apps/www/src/app/(profile)/inbox/components/sent/messages.tsx @@ -0,0 +1,24 @@ +import { SentMessagesList } from "./list"; +import { getSentMessages } from "../../queries"; + +export async function SentMessages({ sessionId }: { sessionId: string }) { + const messages = await getSentMessages(sessionId); + + return ( +
+ {!messages?.length ? ( +

+ No messages to show +

+ ) : ( + + )} +
+ ); +} diff --git a/apps/www/src/app/register/loading.tsx b/apps/www/src/app/(profile)/inbox/loading.tsx similarity index 100% rename from apps/www/src/app/register/loading.tsx rename to apps/www/src/app/(profile)/inbox/loading.tsx diff --git a/apps/www/src/app/inbox/page.tsx b/apps/www/src/app/(profile)/inbox/page.tsx similarity index 98% rename from apps/www/src/app/inbox/page.tsx rename to apps/www/src/app/(profile)/inbox/page.tsx index d6e72868..4d4b362c 100644 --- a/apps/www/src/app/inbox/page.tsx +++ b/apps/www/src/app/(profile)/inbox/page.tsx @@ -9,7 +9,7 @@ import { TabsList, TabsTrigger, } from "@umamin/ui/components/tabs"; -import { UserCard } from "../components/user-card"; +import { UserCard } from "@/app/components/user-card"; import { SentMessages } from "./components/sent/messages"; import { Skeleton } from "@umamin/ui/components/skeleton"; import { ReceivedMessages } from "./components/received/messages"; diff --git a/apps/www/src/app/(profile)/inbox/queries.ts b/apps/www/src/app/(profile)/inbox/queries.ts new file mode 100644 index 00000000..534052f6 --- /dev/null +++ b/apps/www/src/app/(profile)/inbox/queries.ts @@ -0,0 +1,78 @@ +import { cache } from "react"; +import getClient from "@/lib/gql/rsc"; +import { ResultOf, graphql } from "gql.tada"; + +import { sentMessageFragment } from "./components/sent/card"; +import { receivedMessageFragment } from "./components/received/card"; + +const RECEIVED_MESSAGES_QUERY = graphql( + ` + query ReceivedMessages($type: String!) { + messages(type: $type) { + __typename + id + createdAt + ...MessageFragment + } + } + `, + [receivedMessageFragment] +); + +const SENT_MESSAGES_QUERY = graphql( + ` + query SentMessages($type: String!) { + messages(type: $type) { + __typename + id + createdAt + ...SentMessageFragment + } + } + `, + [sentMessageFragment] +); + +const receivedMessagesPersisted = graphql.persisted( + "f07a17f7e44b839d7a1449115b9810d55447696a558d7416f16dc0b9c978217f", + RECEIVED_MESSAGES_QUERY +); + +const sentMessagesPersisted = graphql.persisted( + "05e86ea80c5038a466e952fe9fceeb57d537e1afbe6575df5f27b44944a1531f", + SENT_MESSAGES_QUERY +); + +export const getReceivedMessages = cache(async (sessionId?: string) => { + const result = await getClient(sessionId).query(receivedMessagesPersisted, { + type: "received", + }); + + return result?.data?.messages; +}); + +export const getSentMessages = cache(async (sessionId?: string) => { + const result = await getClient(sessionId).query(sentMessagesPersisted, { + type: "sent", + }); + + return result?.data?.messages; +}); + +export type ReceivedMessagesResult = ResultOf< + typeof RECEIVED_MESSAGES_QUERY +>["messages"]; + +export type SentMessageResult = ResultOf< + typeof SENT_MESSAGES_QUERY +>["messages"]; + +type Cursor = { + id: string | null; + createdAt: number | null; +}; + +export type InboxProps = { + messages?: T extends "received" ? ReceivedMessagesResult : SentMessageResult; + initialCursor: Cursor; +}; diff --git a/apps/www/src/app/settings/components/account-form.tsx b/apps/www/src/app/(profile)/settings/components/account-form.tsx similarity index 100% rename from apps/www/src/app/settings/components/account-form.tsx rename to apps/www/src/app/(profile)/settings/components/account-form.tsx diff --git a/apps/www/src/app/settings/components/account.tsx b/apps/www/src/app/(profile)/settings/components/account.tsx similarity index 100% rename from apps/www/src/app/settings/components/account.tsx rename to apps/www/src/app/(profile)/settings/components/account.tsx diff --git a/apps/www/src/app/settings/components/danger.tsx b/apps/www/src/app/(profile)/settings/components/danger.tsx similarity index 100% rename from apps/www/src/app/settings/components/danger.tsx rename to apps/www/src/app/(profile)/settings/components/danger.tsx diff --git a/apps/www/src/app/settings/components/delete-button.tsx b/apps/www/src/app/(profile)/settings/components/delete-button.tsx similarity index 100% rename from apps/www/src/app/settings/components/delete-button.tsx rename to apps/www/src/app/(profile)/settings/components/delete-button.tsx diff --git a/apps/www/src/app/settings/components/general.tsx b/apps/www/src/app/(profile)/settings/components/general.tsx similarity index 95% rename from apps/www/src/app/settings/components/general.tsx rename to apps/www/src/app/(profile)/settings/components/general.tsx index dee13482..f3eaaece 100644 --- a/apps/www/src/app/settings/components/general.tsx +++ b/apps/www/src/app/(profile)/settings/components/general.tsx @@ -34,6 +34,11 @@ const UPDATE_USER_MUTATION = graphql(` } `); +const updateUserPersisted = graphql.persisted( + "0eb5223468cd7923b5d9a12fc2104425d047f9d34a871160b2e8d37bfc9224fc", + UPDATE_USER_MUTATION +); + const FormSchema = z.object({ question: z .string() @@ -90,7 +95,7 @@ export function GeneralSettings({ user }: { user: CurrentUserResult }) { setSaving(true); - const res = await client.mutation(UPDATE_USER_MUTATION, { + const res = await client.mutation(updateUserPersisted, { input: { ...data, username: data.username.toLowerCase(), @@ -147,7 +152,7 @@ export function GeneralSettings({ user }: { user: CurrentUserResult }) { {account ? ( - Previous usernames will be available to others + Your previous username will be available to other users. ) : ( diff --git a/apps/www/src/app/settings/components/privacy.tsx b/apps/www/src/app/(profile)/settings/components/privacy.tsx similarity index 88% rename from apps/www/src/app/settings/components/privacy.tsx rename to apps/www/src/app/(profile)/settings/components/privacy.tsx index 4abc21ce..b662e9db 100644 --- a/apps/www/src/app/settings/components/privacy.tsx +++ b/apps/www/src/app/(profile)/settings/components/privacy.tsx @@ -27,6 +27,16 @@ const UPDATE_QUIET_MODE_MUTATION = graphql(` } `); +const updatePicturePersisted = graphql.persisted( + "84fbec200028bf15058bc1addbef6e960ac4013dfb8c03205440e89e9f6cb19d", + UPDATE_PICTURE_MUTATION, +); + +const updateQuietModePersisted = graphql.persisted( + "8c072442a1cbead14dc07404113b1dc2e3473fbd060fb5658f2c0acf6189a6c7", + UPDATE_QUIET_MODE_MUTATION, +); + export function PrivacySettings({ user }: { user: CurrentUserResult }) { const router = useRouter(); const [loading, setLoading] = useState(false); @@ -42,7 +52,7 @@ export function PrivacySettings({ user }: { user: CurrentUserResult }) { return; } - const res = await client.mutation(UPDATE_PICTURE_MUTATION, { + const res = await client.mutation(updatePicturePersisted, { imageUrl: picture ? null : user?.accounts[0]?.picture, }); @@ -63,7 +73,7 @@ export function PrivacySettings({ user }: { user: CurrentUserResult }) { const toggleQuietMode = async () => { setLoading(true); - const res = await client.mutation(UPDATE_QUIET_MODE_MUTATION, { + const res = await client.mutation(updateQuietModePersisted, { quietMode: !quietMode, }); diff --git a/apps/www/src/app/settings/components/sign-out-button.tsx b/apps/www/src/app/(profile)/settings/components/sign-out-button.tsx similarity index 100% rename from apps/www/src/app/settings/components/sign-out-button.tsx rename to apps/www/src/app/(profile)/settings/components/sign-out-button.tsx diff --git a/apps/www/src/app/(profile)/settings/loading.tsx b/apps/www/src/app/(profile)/settings/loading.tsx new file mode 100644 index 00000000..e1cdb72b --- /dev/null +++ b/apps/www/src/app/(profile)/settings/loading.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from "@umamin/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ + +
+ +
+
+ + + +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ + +
+ ); +} diff --git a/apps/www/src/app/settings/page.tsx b/apps/www/src/app/(profile)/settings/page.tsx similarity index 89% rename from apps/www/src/app/settings/page.tsx rename to apps/www/src/app/(profile)/settings/page.tsx index a0d20afa..ecf50f0a 100644 --- a/apps/www/src/app/settings/page.tsx +++ b/apps/www/src/app/(profile)/settings/page.tsx @@ -1,10 +1,8 @@ -import { redirect } from "next/navigation"; - import { logout } from "@/actions"; -import getClient from "@/lib/gql/rsc"; import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; -import { CURRENT_USER_QUERY } from "./queries"; +import { getCurrentUser } from "./queries"; import { GeneralSettings } from "./components/general"; import { AccountSettings } from "./components/account"; import { PrivacySettings } from "./components/privacy"; @@ -16,7 +14,6 @@ import { TabsList, TabsTrigger, } from "@umamin/ui/components/tabs"; -import { cache } from "react"; export const metadata = { title: "Umamin — Settings", @@ -41,12 +38,6 @@ export const metadata = { }, }; -const getUser = cache(async (sessionId?: string) => { - const result = await getClient(sessionId).query(CURRENT_USER_QUERY, {}); - - return result?.data?.user; -}); - export default async function Settings() { const { user, session } = await getSession(); @@ -54,7 +45,7 @@ export default async function Settings() { redirect("/login"); } - const userData = await getUser(session?.id); + const userData = await getCurrentUser(session?.id); const tabsData = [ { diff --git a/apps/www/src/app/settings/queries.ts b/apps/www/src/app/(profile)/settings/queries.ts similarity index 53% rename from apps/www/src/app/settings/queries.ts rename to apps/www/src/app/(profile)/settings/queries.ts index cd919ac7..385027f5 100644 --- a/apps/www/src/app/settings/queries.ts +++ b/apps/www/src/app/(profile)/settings/queries.ts @@ -1,3 +1,5 @@ +import { cache } from "react"; +import getClient from "@/lib/gql/rsc"; import { graphql, ResultOf } from "gql.tada"; export const CURRENT_USER_QUERY = graphql(` @@ -23,4 +25,15 @@ export const CURRENT_USER_QUERY = graphql(` } `); +const currentUserPersisted = graphql.persisted( + "3f2320bbe96bd7895f618b6cdedfdee5d2f40e3e0c1d75095ea0844a0ff107b4", + CURRENT_USER_QUERY, +); + +export const getCurrentUser = cache(async (sessionId?: string) => { + const result = await getClient(sessionId).query(currentUserPersisted, {}); + + return result?.data?.user; +}); + export type CurrentUserResult = ResultOf["user"]; diff --git a/apps/www/src/app/(user)/queries.ts b/apps/www/src/app/(user)/queries.ts new file mode 100644 index 00000000..ce853087 --- /dev/null +++ b/apps/www/src/app/(user)/queries.ts @@ -0,0 +1,37 @@ +import { cache } from "react"; +import getClient from "@/lib/gql/rsc"; +import { ResultOf, graphql } from "gql.tada"; + +export const USER_BY_USERNAME_QUERY = graphql(` + query UserByUsername($username: String!) { + userByUsername(username: $username) { + __typename + id + bio + question + username + displayName + question + quietMode + imageUrl + createdAt + } + } +`); + +const userByUsernamePersisted = graphql.persisted( + "e56708c4cacdba6c698de1f4bc45a999ca535fb519bd806caf9a62a6582159e4", + USER_BY_USERNAME_QUERY, +); + +export const getUserByUsername = cache(async (username: string) => { + const result = await getClient().query(userByUsernamePersisted, { + username, + }); + + return result.data?.userByUsername; +}); + +export type UserByUsernameQueryResult = ResultOf< + typeof USER_BY_USERNAME_QUERY +>["userByUsername"]; diff --git a/apps/www/src/app/to/[username]/components/form.tsx b/apps/www/src/app/(user)/to/[username]/components/form.tsx similarity index 92% rename from apps/www/src/app/to/[username]/components/form.tsx rename to apps/www/src/app/(user)/to/[username]/components/form.tsx index f46381f0..c4583143 100644 --- a/apps/www/src/app/to/[username]/components/form.tsx +++ b/apps/www/src/app/(user)/to/[username]/components/form.tsx @@ -12,10 +12,10 @@ import { cn } from "@umamin/ui/lib/utils"; import { formatError } from "@/lib/utils"; import { Button } from "@umamin/ui/components/button"; import { ChatList } from "@/app/components/chat-list"; -import { UserByUsernameQueryResult } from "../queries"; import useBotDetection from "@/hooks/use-bot-detection"; import { Textarea } from "@umamin/ui/components/textarea"; import { useDynamicTextarea } from "@/hooks/use-dynamic-textarea"; +import type { UserByUsernameQueryResult } from "../../../queries"; const CREATE_MESSAGE_MUTATION = graphql(` mutation CreateMessage($input: CreateMessageInput!) { @@ -25,6 +25,11 @@ const CREATE_MESSAGE_MUTATION = graphql(` } `); +const createMessagePersisted = graphql.persisted( + "3550bab6df63cc9b4f891263677b487dbf67eba1b5cc9af9fec5fc037d2e49f0", + CREATE_MESSAGE_MUTATION, +); + type Props = { currentUserId?: string; user: UserByUsernameQueryResult; @@ -54,7 +59,7 @@ export default function ChatForm({ currentUserId, user }: Props) { setIsFetching(true); try { - const res = await client.mutation(CREATE_MESSAGE_MUTATION, { + const res = await client.mutation(createMessagePersisted, { input: { senderId: currentUserId, receiverId: user?.id, diff --git a/apps/www/src/app/to/[username]/components/unauthenticated.tsx b/apps/www/src/app/(user)/to/[username]/components/unauthenticated.tsx similarity index 100% rename from apps/www/src/app/to/[username]/components/unauthenticated.tsx rename to apps/www/src/app/(user)/to/[username]/components/unauthenticated.tsx diff --git a/apps/www/src/app/to/[username]/loading.tsx b/apps/www/src/app/(user)/to/[username]/loading.tsx similarity index 94% rename from apps/www/src/app/to/[username]/loading.tsx rename to apps/www/src/app/(user)/to/[username]/loading.tsx index d1e3032e..60b97e44 100644 --- a/apps/www/src/app/to/[username]/loading.tsx +++ b/apps/www/src/app/(user)/to/[username]/loading.tsx @@ -4,7 +4,7 @@ export default function Loading() { return (
- +
diff --git a/apps/www/src/app/to/[username]/page.tsx b/apps/www/src/app/(user)/to/[username]/page.tsx similarity index 76% rename from apps/www/src/app/to/[username]/page.tsx rename to apps/www/src/app/(user)/to/[username]/page.tsx index 18d2ae49..dced0d18 100644 --- a/apps/www/src/app/to/[username]/page.tsx +++ b/apps/www/src/app/(user)/to/[username]/page.tsx @@ -1,14 +1,13 @@ -import { cache } from "react"; import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; import { BadgeCheck, Lock, MessageCircleOff } from "lucide-react"; -import getClient from "@/lib/gql/rsc"; import { getSession } from "@/lib/auth"; -import { USER_BY_USERNAME_QUERY } from "./queries"; +import { getUserByUsername } from "../../queries"; import { ShareButton } from "@/app/components/share-button"; import { Card, CardHeader } from "@umamin/ui/components/card"; +const AdContainer = dynamic(() => import("@umamin/ui/ad")); const ChatForm = dynamic(() => import("./components/form")); const UnauthenticatedDialog = dynamic( () => import("./components/unauthenticated"), @@ -52,20 +51,12 @@ export async function generateMetadata({ }; } -const getUser = cache(async (username: string) => { - const result = await getClient().query(USER_BY_USERNAME_QUERY, { - username, - }); - - return result.data?.userByUsername; -}); - export default async function SendMessage({ params, }: { params: { username: string }; }) { - const user = await getUser(params.username); + const user = await getUserByUsername(params.username); if (!user) { redirect("/404"); @@ -74,7 +65,10 @@ export default async function SendMessage({ const { session } = await getSession(); return ( -
+
+ {/* v2-send-to */} + +
@@ -93,16 +87,6 @@ export default async function SendMessage({
- {/* -

@{user.username}

-
*/} - umamin @@ -112,16 +96,12 @@ export default async function SendMessage({
-
+
- Messages are automatically encrypted + Advanced Encryption Standard
- {/* v2-send-to - - */} - ); diff --git a/apps/www/src/app/user/[username]/loading.tsx b/apps/www/src/app/(user)/user/[username]/loading.tsx similarity index 100% rename from apps/www/src/app/user/[username]/loading.tsx rename to apps/www/src/app/(user)/user/[username]/loading.tsx diff --git a/apps/www/src/app/user/[username]/page.tsx b/apps/www/src/app/(user)/user/[username]/page.tsx similarity index 77% rename from apps/www/src/app/user/[username]/page.tsx rename to apps/www/src/app/(user)/user/[username]/page.tsx index 25a17351..b2ff2988 100644 --- a/apps/www/src/app/user/[username]/page.tsx +++ b/apps/www/src/app/(user)/user/[username]/page.tsx @@ -1,32 +1,15 @@ import Link from "next/link"; -import { cache } from "react"; -import { graphql } from "gql.tada"; import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; import { MessageSquareMore, UserPlus } from "lucide-react"; -import getClient from "@/lib/gql/rsc"; import { cn } from "@umamin/ui/lib/utils"; +import { getUserByUsername } from "../../queries"; import { UserCard } from "@/app/components/user-card"; import { Button, buttonVariants } from "@umamin/ui/components/button"; const AdContainer = dynamic(() => import("@umamin/ui/ad"), { ssr: false }); -const USER_BY_USERNAME_QUERY = graphql(` - query UserByUsername($username: String!) { - userByUsername(username: $username) { - __typename - id - bio - username - displayName - imageUrl - createdAt - quietMode - } - } -`); - export async function generateMetadata({ params, }: { @@ -68,20 +51,12 @@ export async function generateMetadata({ }; } -const getUser = cache(async (username: string) => { - const result = await getClient().query(USER_BY_USERNAME_QUERY, { - username, - }); - - return result.data?.userByUsername; -}); - export default async function Page({ params, }: { params: { username: string }; }) { - const user = await getUser(params.username); + const user = await getUserByUsername(params.username); if (!user) { redirect("/404"); diff --git a/apps/www/src/app/api/graphql/route.ts b/apps/www/src/app/api/graphql/route.ts index 8ffa8377..6efa7f35 100644 --- a/apps/www/src/app/api/graphql/route.ts +++ b/apps/www/src/app/api/graphql/route.ts @@ -1,10 +1,11 @@ import { cookies } from "next/headers"; import { createYoga } from "graphql-yoga"; import { getSession, lucia } from "@/lib/auth"; -import { useAPQ } from "@graphql-yoga/plugin-apq"; import { gqlSchema, initContextCache } from "@umamin/gql"; +import persistedOperations from "@/persisted-operations.json"; import { useResponseCache } from "@graphql-yoga/plugin-response-cache"; import { useCSRFPrevention } from "@graphql-yoga/plugin-csrf-prevention"; +import { usePersistedOperations } from "@graphql-yoga/plugin-persisted-operations"; import { useDisableIntrospection } from "@graphql-yoga/plugin-disable-introspection"; const { handleRequest } = createYoga({ @@ -44,15 +45,48 @@ const { handleRequest } = createYoga({ }, ttl: 30_000, ttlPerSchemaCoordinate: { - "Query.notes": 180_000, - "Query.notesFromCursor": 180_000, - "Query.userByUsername": 300_000, + "Query.notes": 120_000, + "Query.notesFromCursor": 120_000, + "Query.userByUsername": 120_000, }, }), useDisableIntrospection({ isDisabled: () => process.env.NODE_ENV === "production", }), - useAPQ(), + usePersistedOperations({ + allowArbitraryOperations: process.env.NODE_ENV === "development", + customErrors: { + notFound: { + message: "Operation is not found", + extensions: { + http: { + status: 404, + }, + }, + }, + keyNotFound: { + message: "Key is not found", + extensions: { + http: { + status: 404, + }, + }, + }, + persistedQueryOnly: { + message: "Operation is not allowed", + extensions: { + http: { + status: 403, + }, + }, + }, + }, + skipDocumentValidation: true, + async getPersistedOperation(key: string) { + // @ts-ignore + return persistedOperations[key]; + }, + }), ], }); diff --git a/apps/www/src/app/components/chat-list.tsx b/apps/www/src/app/components/chat-list.tsx index b0e78eab..8df8a8cb 100644 --- a/apps/www/src/app/components/chat-list.tsx +++ b/apps/www/src/app/components/chat-list.tsx @@ -34,7 +34,7 @@ export const ChatList = ({ imageUrl, question, reply, response }: Props) => { )} {response && ( -
+
diff --git a/apps/www/src/app/components/navbar.tsx b/apps/www/src/app/components/navbar.tsx index 668b9afd..bd1482ee 100644 --- a/apps/www/src/app/components/navbar.tsx +++ b/apps/www/src/app/components/navbar.tsx @@ -24,7 +24,7 @@ export async function Navbar() { : "v2.0.0"; return ( -