├── .npmrc
├── packages
├── ui
│ ├── src
│ │ ├── hooks
│ │ │ └── .gitkeep
│ │ ├── lib
│ │ │ └── utils.ts
│ │ └── components
│ │ │ ├── skeleton.tsx
│ │ │ ├── label.tsx
│ │ │ ├── sonner.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── input.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── button.tsx
│ │ │ └── card.tsx
│ ├── postcss.config.mjs
│ ├── components.json
│ ├── tsconfig.json
│ └── package.json
├── db
│ ├── .gitignore
│ ├── src
│ │ ├── schema
│ │ │ ├── index.ts
│ │ │ ├── note.ts
│ │ │ ├── message.ts
│ │ │ └── user.ts
│ │ └── index.ts
│ ├── migrations
│ │ ├── meta
│ │ │ └── _journal.json
│ │ └── 0000_fresh_shard.sql
│ ├── drizzle.config.ts
│ ├── tsconfig.json
│ └── package.json
└── encryption
│ ├── tsconfig.json
│ ├── src
│ ├── generate-aes-key.ts
│ └── index.ts
│ └── package.json
├── apps
└── www
│ ├── public
│ ├── ads.txt
│ ├── vercel.svg
│ ├── window.svg
│ ├── file.svg
│ ├── globe.svg
│ └── next.svg
│ ├── app
│ ├── icon.png
│ ├── favicon.ico
│ ├── apple-icon.png
│ ├── twitter-image.png
│ ├── opengraph-image.png
│ ├── globals.css
│ ├── inbox
│ │ ├── layout.tsx
│ │ ├── components
│ │ │ ├── current-user-card.tsx
│ │ │ ├── received
│ │ │ │ ├── received-message-card-skeleton.tsx
│ │ │ │ ├── received-card.tsx
│ │ │ │ ├── received-card-menu.tsx
│ │ │ │ └── reply-dialog.tsx
│ │ │ ├── sent
│ │ │ │ ├── sent-message-card-skeleton.tsx
│ │ │ │ └── sent-card.tsx
│ │ │ └── inbox-tabs.tsx
│ │ └── page.tsx
│ ├── login
│ │ ├── layout.tsx
│ │ ├── components
│ │ │ └── login-form.tsx
│ │ └── page.tsx
│ ├── notes
│ │ ├── layout.tsx
│ │ ├── components
│ │ │ ├── note-card-skeleton.tsx
│ │ │ └── note-form.tsx
│ │ └── page.tsx
│ ├── social
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── components
│ │ │ └── social-card.tsx
│ ├── register
│ │ ├── layout.tsx
│ │ ├── components
│ │ │ └── register-form.tsx
│ │ └── page.tsx
│ ├── settings
│ │ ├── layout.tsx
│ │ ├── components
│ │ │ ├── sign-out-button.tsx
│ │ │ ├── danger-button.tsx
│ │ │ ├── settings-skeleton.tsx
│ │ │ ├── danger-settings.tsx
│ │ │ ├── settings-tabs.tsx
│ │ │ ├── password-form.tsx
│ │ │ └── account-settings.tsx
│ │ └── page.tsx
│ ├── to
│ │ └── [username]
│ │ │ ├── layout.tsx
│ │ │ ├── components
│ │ │ ├── chat-form-skeleton.tsx
│ │ │ └── chat-form.tsx
│ │ │ ├── loading.tsx
│ │ │ └── page.tsx
│ ├── robots.ts
│ ├── terms
│ │ └── page.tsx
│ ├── privacy
│ │ └── page.tsx
│ ├── user
│ │ └── [username]
│ │ │ ├── loading.tsx
│ │ │ └── page.tsx
│ ├── auth
│ │ └── google
│ │ │ └── route.ts
│ ├── providers.tsx
│ ├── sitemap.ts
│ ├── page.tsx
│ ├── not-found.tsx
│ ├── api
│ │ ├── users
│ │ │ └── [username]
│ │ │ │ └── route.ts
│ │ ├── notes
│ │ │ └── route.ts
│ │ └── messages
│ │ │ └── route.ts
│ ├── error.tsx
│ ├── global-error.tsx
│ ├── actions
│ │ └── note.ts
│ └── layout.tsx
│ ├── types
│ ├── index.ts
│ ├── lucide-react.d.ts
│ └── user.ts
│ ├── postcss.config.mjs
│ ├── mdx-components.tsx
│ ├── lib
│ ├── oauth.ts
│ ├── schema.ts
│ ├── avatar.ts
│ ├── get-query-client.ts
│ ├── utils.ts
│ └── session.ts
│ ├── components
│ ├── loading-icon.tsx
│ ├── hover-prefetch-link.tsx
│ ├── footer.tsx
│ ├── share-button.tsx
│ ├── animated-shiny-text.tsx
│ ├── grid-pattern.tsx
│ ├── menu.tsx
│ ├── copy-link.tsx
│ ├── theme-toggle.tsx
│ ├── ad-container.tsx
│ ├── skeleton
│ │ ├── user-card-skeleton.tsx
│ │ └── chat-list-skeleton.tsx
│ ├── unauthenticated-dialog.tsx
│ ├── chat-list.tsx
│ ├── browser-warning.tsx
│ ├── share-link-dialog.tsx
│ ├── demo.tsx
│ ├── navbar.tsx
│ └── user-card.tsx
│ ├── components.json
│ ├── hooks
│ ├── use-media-query.tsx
│ ├── use-dynamic-textarea.ts
│ └── form.tsx
│ ├── .gitignore
│ ├── next.config.ts
│ ├── tsconfig.json
│ ├── README.md
│ ├── proxy.ts
│ ├── package.json
│ └── markdown
│ ├── privacy.mdx
│ └── terms.mdx
├── .vscode
└── settings.json
├── pnpm-workspace.yaml
├── lefthook.yml
├── .gitignore
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ └── ci.yml
├── turbo.json
├── biome.json
├── package.json
└── SECURITY.md
/.npmrc:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/db/.gitignore:
--------------------------------------------------------------------------------
1 | local.db*
2 |
--------------------------------------------------------------------------------
/apps/www/public/ads.txt:
--------------------------------------------------------------------------------
1 | google.com, pub-4274133898976040, DIRECT, f08c47fec0942fa0
2 |
--------------------------------------------------------------------------------
/apps/www/app/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omsimos/umamin/HEAD/apps/www/app/icon.png
--------------------------------------------------------------------------------
/apps/www/types/index.ts:
--------------------------------------------------------------------------------
1 | export type Cursor = {
2 | id: string;
3 | date: Date;
4 | };
5 |
--------------------------------------------------------------------------------
/apps/www/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omsimos/umamin/HEAD/apps/www/app/favicon.ico
--------------------------------------------------------------------------------
/apps/www/app/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omsimos/umamin/HEAD/apps/www/app/apple-icon.png
--------------------------------------------------------------------------------
/apps/www/app/twitter-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omsimos/umamin/HEAD/apps/www/app/twitter-image.png
--------------------------------------------------------------------------------
/apps/www/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/omsimos/umamin/HEAD/apps/www/app/opengraph-image.png
--------------------------------------------------------------------------------
/packages/db/src/schema/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./message";
2 | export * from "./note";
3 | export * from "./user";
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [
3 | {
4 | "mode": "auto"
5 | }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
5 | onlyBuiltDependencies:
6 | - lefthook
7 |
--------------------------------------------------------------------------------
/apps/www/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/apps/www/app/globals.css:
--------------------------------------------------------------------------------
1 | /* File added for LSP support */
2 | @import "@umamin/ui/globals.css";
3 | @plugin "@tailwindcss/typography";
4 |
--------------------------------------------------------------------------------
/apps/www/types/lucide-react.d.ts:
--------------------------------------------------------------------------------
1 | declare module "lucide-react" {
2 | export * from "lucide-react/dist/lucide-react.suffixed";
3 | }
4 |
--------------------------------------------------------------------------------
/apps/www/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/ui/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: { "@tailwindcss/postcss": {} },
4 | };
5 |
6 | export default config;
7 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/apps/www/app/login/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/apps/www/app/notes/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/apps/www/app/social/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/apps/www/app/register/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/apps/www/app/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/apps/www/app/to/[username]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return {children};
5 | }
6 |
--------------------------------------------------------------------------------
/apps/www/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | import type { MDXComponents } from "mdx/types";
2 |
3 | const components: MDXComponents = {};
4 |
5 | export function useMDXComponents(): MDXComponents {
6 | return components;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/ui/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/apps/www/lib/oauth.ts:
--------------------------------------------------------------------------------
1 | import { Google } from "arctic";
2 |
3 | export const google = new Google(
4 | process.env.GOOGLE_CLIENT_ID ?? "",
5 | process.env.GOOGLE_CLIENT_SECRET ?? "",
6 | process.env.GOOGLE_REDIRECT_URI ?? "",
7 | );
8 |
--------------------------------------------------------------------------------
/lefthook.yml:
--------------------------------------------------------------------------------
1 | pre-commit:
2 | commands:
3 | check:
4 | glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
5 | run: pnpm exec biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
6 | stage_fixed: true
7 |
--------------------------------------------------------------------------------
/packages/db/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "sqlite",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "6",
8 | "when": 1719486511741,
9 | "tag": "0000_fresh_shard",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/packages/db/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "drizzle-kit";
2 |
3 | export default defineConfig({
4 | schema: "./src/schema",
5 | out: "./migrations",
6 | dialect: "turso",
7 | dbCredentials: {
8 | url: process.env.TURSO_CONNECTION_URL ?? "",
9 | authToken: process.env.TURSO_AUTH_TOKEN ?? "",
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/components/current-user-card.tsx:
--------------------------------------------------------------------------------
1 | import { UserCard } from "@/components/user-card";
2 | import { getSession } from "@/lib/auth";
3 |
4 | export async function CurrentUserCard() {
5 | const { user } = await getSession();
6 |
7 | if (!user) {
8 | return null;
9 | }
10 |
11 | return ;
12 | }
13 |
--------------------------------------------------------------------------------
/packages/ui/src/components/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@umamin/ui/lib/utils";
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | );
11 | }
12 |
13 | export { Skeleton };
14 |
--------------------------------------------------------------------------------
/apps/www/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/www/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/www/app/robots.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from "next";
2 |
3 | export default function robots(): MetadataRoute.Robots {
4 | const base = "https://www.umamin.link";
5 | return {
6 | rules: [
7 | {
8 | userAgent: "*",
9 | allow: "/",
10 | disallow: ["/api/*", "/auth/*", "/inbox/*", "/settings/*"],
11 | },
12 | ],
13 | sitemap: `${base}/sitemap.xml`,
14 | host: base,
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/apps/www/components/loading-icon.tsx:
--------------------------------------------------------------------------------
1 | import { Loader2Icon, type LucideIcon } from "lucide-react";
2 |
3 | type Props = {
4 | loading: boolean;
5 | icon?: LucideIcon;
6 | };
7 | export function LoadingIcon({ loading, icon }: Props) {
8 | const Icon = icon;
9 | return (
10 | <>
11 | {loading ? (
12 |
13 | ) : (
14 | Icon &&
15 | )}
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/packages/db/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 | "strict": true,
12 | "noUncheckedIndexedAccess": true,
13 | "module": "NodeNext"
14 | },
15 | "include": ["src"],
16 | "exclude": ["node_modules"]
17 | }
18 |
--------------------------------------------------------------------------------
/packages/encryption/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 | "strict": true,
12 | "noUncheckedIndexedAccess": true,
13 | "module": "NodeNext"
14 | },
15 | "include": ["src"],
16 | "exclude": ["node_modules"]
17 | }
18 |
--------------------------------------------------------------------------------
/apps/www/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "../../packages/ui/src/styles/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "iconLibrary": "lucide",
13 | "aliases": {
14 | "components": "@/components",
15 | "hooks": "@/hooks",
16 | "lib": "@/lib",
17 | "utils": "@umamin/ui/lib/utils",
18 | "ui": "@umamin/ui/components"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true
11 | },
12 | "iconLibrary": "lucide",
13 | "aliases": {
14 | "components": "@umamin/ui/components",
15 | "utils": "@umamin/ui/lib/utils",
16 | "hooks": "@umamin/ui/hooks",
17 | "lib": "@umamin/ui/lib",
18 | "ui": "@umamin/ui/components"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/apps/www/hooks/use-media-query.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useMediaQuery(query: string) {
4 | const [value, setValue] = useState(false);
5 |
6 | useEffect(() => {
7 | function onChange(event: MediaQueryListEvent) {
8 | setValue(event.matches);
9 | }
10 |
11 | const result = matchMedia(query);
12 | result.addEventListener("change", onChange);
13 | setValue(result.matches);
14 |
15 | return () => result.removeEventListener("change", onChange);
16 | }, [query]);
17 |
18 | return value;
19 | }
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # Dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # Local env files
9 | .env
10 | .env.local
11 | .env.development.local
12 | .env.test.local
13 | .env.production.local
14 |
15 | # Testing
16 | coverage
17 |
18 | # Turbo
19 | .turbo
20 |
21 | # Vercel
22 | .vercel
23 |
24 | # Build Outputs
25 | .next/
26 | out/
27 | build
28 | dist
29 |
30 |
31 | # Debug
32 | npm-debug.log*
33 | yarn-debug.log*
34 | yarn-error.log*
35 |
36 | # Misc
37 | .DS_Store
38 | *.pem
39 |
--------------------------------------------------------------------------------
/packages/db/src/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from "drizzle-orm/libsql";
2 | import * as schema from "./schema";
3 |
4 | export const db = drizzle({
5 | connection: {
6 | url: process.env.TURSO_CONNECTION_URL ?? "",
7 | authToken: process.env.TURSO_AUTH_TOKEN ?? "",
8 | },
9 | // cache:
10 | // process.env.NODE_ENV === "production"
11 | // ? upstashCache({
12 | // url: process.env.UPSTASH_REDIS_REST_URL ?? "",
13 | // token: process.env.UPSTASH_REDIS_REST_TOKEN ?? "",
14 | // global: true,
15 | // })
16 | // : undefined,
17 | schema,
18 | });
19 |
--------------------------------------------------------------------------------
/apps/www/app/terms/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import TermsOfService from "@/markdown/terms.mdx";
3 |
4 | export const metadata: Metadata = {
5 | title: "Umamin — Terms of Service",
6 | description:
7 | "Understand the terms and conditions for using Umamin, an open-source platform for sending and receiving encrypted anonymous messages.",
8 | };
9 |
10 | export default function Page() {
11 | return (
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/apps/www/components/hover-prefetch-link.tsx:
--------------------------------------------------------------------------------
1 | import Link, { type LinkProps } from "next/link";
2 | import { useState } from "react";
3 |
4 | type Props = {
5 | children: React.ReactNode;
6 | className?: string;
7 | } & LinkProps;
8 |
9 | export function HoverPrefetchLink({ children, className, ...rest }: Props) {
10 | const [active, setActive] = useState(false);
11 |
12 | return (
13 | setActive(true)}
18 | >
19 | {children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/apps/www/app/privacy/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import Privacy from "@/markdown/privacy.mdx";
3 |
4 | export const metadata: Metadata = {
5 | title: "Umamin — Privacy Policy",
6 | description:
7 | "Learn how Umamin, an open-source platform for sending and receiving encrypted anonymous messages, collects, uses, and protects your personal information.",
8 | };
9 |
10 | export default function Page() {
11 | return (
12 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/packages/encryption/src/generate-aes-key.ts:
--------------------------------------------------------------------------------
1 | const { subtle } = globalThis.crypto;
2 |
3 | async function main() {
4 | try {
5 | const key = await subtle.generateKey(
6 | { name: "AES-GCM", length: 256 },
7 | true,
8 | ["encrypt", "decrypt"],
9 | );
10 | const rawKey = await subtle.exportKey("raw", key);
11 | const base64Key = Buffer.from(new Uint8Array(rawKey)).toString("base64");
12 | console.log("Generated AES-256-GCM key (base64):");
13 | console.log(base64Key);
14 | } catch (err) {
15 | console.error("Failed to generate key:", err);
16 | process.exit(1);
17 | }
18 | }
19 |
20 | main();
21 |
--------------------------------------------------------------------------------
/packages/encryption/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@umamin/encryption",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | ".": {
7 | "types": "./dist/index.d.mts",
8 | "default": "./dist/index.mjs"
9 | }
10 | },
11 | "scripts": {
12 | "dev": "tsdown --watch ./src",
13 | "build": "tsdown",
14 | "generate": "tsx ./src/generate-aes-key.ts",
15 | "clean": "rm -rf ./node_modules .turbo dist",
16 | "check-types": "tsc --noEmit"
17 | },
18 | "devDependencies": {
19 | "@types/node": "^22.19.3",
20 | "tsdown": "^0.18.0",
21 | "tsx": "^4.21.0",
22 | "typescript": "^5.9.3"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/apps/www/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/packages/ui/src/components/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as LabelPrimitive from "@radix-ui/react-label";
4 | import { cn } from "@umamin/ui/lib/utils";
5 |
6 | function Label({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 | );
20 | }
21 |
22 | export { Label };
23 |
--------------------------------------------------------------------------------
/apps/www/next.config.ts:
--------------------------------------------------------------------------------
1 | import createMDX from "@next/mdx";
2 |
3 | import type { NextConfig } from "next";
4 |
5 | const nextConfig: NextConfig = {
6 | reactCompiler: true,
7 | cacheComponents: true,
8 | experimental: {
9 | turbopackFileSystemCacheForDev: true,
10 | },
11 | images: {
12 | remotePatterns: [new URL("https://lh3.googleusercontent.com/a/**")],
13 | },
14 | compiler: {
15 | removeConsole: process.env.NODE_ENV === "production",
16 | },
17 | transpilePackages: ["@umamin/db", "@umamin/encryption", "@umamin/ui"],
18 | };
19 |
20 | const withMDX = createMDX({
21 | options: {
22 | remarkPlugins: ["remark-gfm"],
23 | },
24 | });
25 |
26 | export default withMDX(nextConfig);
27 |
--------------------------------------------------------------------------------
/packages/ui/src/components/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 | import { Toaster as Sonner, type ToasterProps } from "sonner";
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = "system" } = useTheme();
8 |
9 | return (
10 |
22 | );
23 | };
24 |
25 | export { Toaster };
26 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ""
5 | labels: "feature request"
6 | assignees: ""
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "jsx": "react-jsx",
5 | "declaration": true,
6 | "declarationMap": true,
7 | "esModuleInterop": true,
8 | "incremental": false,
9 | "isolatedModules": true,
10 | "lib": ["es2022", "DOM", "DOM.Iterable"],
11 | "module": "NodeNext",
12 | "moduleDetection": "force",
13 | "moduleResolution": "NodeNext",
14 | "noUncheckedIndexedAccess": true,
15 | "resolveJsonModule": true,
16 | "skipLibCheck": true,
17 | "strict": true,
18 | "target": "ES2022",
19 | "paths": {
20 | "@umamin/ui/*": ["./src/*"]
21 | }
22 | },
23 | "include": ["."],
24 | "exclude": ["node_modules", "dist"]
25 | }
26 |
--------------------------------------------------------------------------------
/apps/www/app/settings/components/sign-out-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQueryClient } from "@tanstack/react-query";
4 | import { Button } from "@umamin/ui/components/button";
5 | import { Loader2Icon, LogOutIcon } from "lucide-react";
6 | import { useFormStatus } from "react-dom";
7 |
8 | export function SignOutButton() {
9 | const { pending } = useFormStatus();
10 | const queryClient = useQueryClient();
11 |
12 | return (
13 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/apps/www/app/user/[username]/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@umamin/ui/components/button";
2 | import { MessageSquareMoreIcon, UserPlusIcon } from "lucide-react";
3 | import { UserCardSkeleton } from "@/components/skeleton/user-card-skeleton";
4 |
5 | export default function Loading() {
6 | return (
7 |
8 |
9 |
10 |
11 |
15 |
16 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/apps/www/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export function Footer() {
4 | return (
5 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/apps/www/app/settings/components/danger-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQueryClient } from "@tanstack/react-query";
4 | import { Button } from "@umamin/ui/components/button";
5 | import { Loader2Icon } from "lucide-react";
6 | import { useFormStatus } from "react-dom";
7 |
8 | export function DeleteButton({ confirmText }: { confirmText: string }) {
9 | const { pending } = useFormStatus();
10 | const queryClient = useQueryClient();
11 |
12 | return (
13 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/apps/www/hooks/use-dynamic-textarea.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useLayoutEffect, useRef } from "react";
2 |
3 | function updateTextAreaSize(textArea?: HTMLTextAreaElement | null) {
4 | if (!textArea) return;
5 | textArea.style.height = "3rem";
6 | textArea.style.height = `${textArea.scrollHeight}px`;
7 | }
8 |
9 | export function useDynamicTextarea(content: string) {
10 | const textAreaRef = useRef(null);
11 |
12 | const inputRef = useCallback((textArea: HTMLTextAreaElement | null) => {
13 | textAreaRef.current = textArea;
14 | updateTextAreaSize(textArea);
15 | }, []);
16 |
17 | // biome-ignore lint/correctness/useExhaustiveDependencies: updates height only when content changes
18 | useLayoutEffect(() => {
19 | updateTextAreaSize(textAreaRef.current);
20 | }, [content]);
21 |
22 | return inputRef;
23 | }
24 |
--------------------------------------------------------------------------------
/packages/ui/src/components/textarea.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@umamin/ui/lib/utils";
2 | import type * as React from "react";
3 |
4 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
5 | return (
6 |
14 | );
15 | }
16 |
17 | export { Textarea };
18 |
--------------------------------------------------------------------------------
/apps/www/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "react-jsx",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "baseUrl": ".",
22 | "paths": {
23 | "@/*": ["./*"],
24 | "@umamin/ui/*": ["../../packages/ui/src/*"]
25 | }
26 | },
27 | "include": [
28 | "**/*.ts",
29 | "**/*.tsx",
30 | "next-env.d.ts",
31 | ".next/types/**/*.ts",
32 | ".next/dev/types/**/*.ts"
33 | ],
34 | "exclude": ["node_modules", ".next"]
35 | }
36 |
--------------------------------------------------------------------------------
/apps/www/lib/schema.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | export const registerSchema = z
4 | .object({
5 | username: z
6 | .string()
7 | .min(5, {
8 | message: "Username must be at least 5 characters",
9 | })
10 | .max(20, {
11 | message: "Username must not exceed 20 characters",
12 | })
13 | .refine((url) => /^[a-zA-Z0-9_-]+$/.test(url), {
14 | message: "Username must be alphanumeric with no spaces",
15 | }),
16 | password: z
17 | .string()
18 | .min(5, {
19 | message: "Password must be at least 5 characters",
20 | })
21 | .max(255, {
22 | message: "Password must not exceed 255 characters",
23 | }),
24 | confirmPassword: z.string(),
25 | })
26 | .refine((data) => data.password === data.confirmPassword, {
27 | message: "Password does not match",
28 | path: ["confirmPassword"],
29 | });
30 |
--------------------------------------------------------------------------------
/apps/www/components/share-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Share2Icon } from "lucide-react";
4 |
5 | const onShare = (username: string) => {
6 | try {
7 | if (typeof window !== "undefined") {
8 | const url = `${window.location.origin}/user/${username}`;
9 |
10 | if (
11 | navigator.share &&
12 | navigator.canShare({ url }) &&
13 | process.env.NODE_ENV === "production"
14 | ) {
15 | navigator.share({ url });
16 | } else {
17 | navigator.clipboard.writeText(
18 | `${window.location.origin}/user/${username}`,
19 | );
20 | }
21 | }
22 | } catch (err) {
23 | console.log(err);
24 | }
25 | };
26 |
27 | export function ShareButton({ username }: { username: string }) {
28 | return (
29 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.com/schema.json",
3 | "globalDependencies": [".env"],
4 | "globalEnv": ["NODE_ENV"],
5 | "ui": "tui",
6 | "tasks": {
7 | "build": {
8 | "dependsOn": ["^build"],
9 | "inputs": ["$TURBO_DEFAULT$", ".env*"],
10 | "outputs": [".next/**", "!.next/cache/**", "dist"],
11 | "env": [
12 | "VERCEL_URL",
13 | "TURSO_*",
14 | "GOOGLE_*",
15 | "UPSTASH_REDIS_*",
16 | "AES_256_GCM_KEY"
17 | ]
18 | },
19 | "lint": {
20 | "dependsOn": ["^lint"]
21 | },
22 | "clean": {
23 | "cache": false,
24 | "dependsOn": ["^clean"]
25 | },
26 | "check-types": {
27 | "dependsOn": ["^check-types"]
28 | },
29 | "dev": {
30 | "cache": false,
31 | "persistent": true
32 | },
33 | "//#format-and-lint": {},
34 | "//#format-and-lint:fix": {
35 | "cache": false
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/ui/src/components/collapsible.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
4 |
5 | function Collapsible({
6 | ...props
7 | }: React.ComponentProps) {
8 | return ;
9 | }
10 |
11 | function CollapsibleTrigger({
12 | ...props
13 | }: React.ComponentProps) {
14 | return (
15 |
19 | );
20 | }
21 |
22 | function CollapsibleContent({
23 | ...props
24 | }: React.ComponentProps) {
25 | return (
26 |
30 | );
31 | }
32 |
33 | export { Collapsible, CollapsibleTrigger, CollapsibleContent };
34 |
--------------------------------------------------------------------------------
/apps/www/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
3 | "vcs": {
4 | "enabled": true,
5 | "clientKind": "git",
6 | "useIgnoreFile": true
7 | },
8 | "files": {
9 | "ignoreUnknown": true,
10 | "includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "space",
15 | "indentWidth": 2
16 | },
17 | "css": {
18 | "parser": {
19 | "tailwindDirectives": true
20 | }
21 | },
22 | "linter": {
23 | "enabled": true,
24 | "rules": {
25 | "recommended": true,
26 | "correctness": {
27 | "noChildrenProp": "off"
28 | },
29 | "suspicious": {
30 | "noUnknownAtRules": "off"
31 | }
32 | },
33 | "domains": {
34 | "next": "recommended",
35 | "react": "recommended"
36 | }
37 | },
38 | "assist": {
39 | "actions": {
40 | "source": {
41 | "organizeImports": "on"
42 | }
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: "bug"
6 | assignees: ""
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 |
28 | - OS: [e.g. iOS]
29 | - Browser [e.g. chrome, safari]
30 | - Version [e.g. 22]
31 |
32 | **Smartphone (please complete the following information):**
33 |
34 | - Device: [e.g. iPhone6]
35 | - OS: [e.g. iOS8.1]
36 | - Browser [e.g. stock browser, safari]
37 | - Version [e.g. 22]
38 |
39 | **Additional context**
40 | Add any other context about the problem here.
41 |
--------------------------------------------------------------------------------
/apps/www/components/animated-shiny-text.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@umamin/ui/lib/utils";
2 | import type { CSSProperties } from "react";
3 |
4 | export const AnimatedShinyText = ({
5 | className,
6 | children,
7 | }: {
8 | className?: string;
9 | children: React.ReactNode;
10 | }) => {
11 | return (
12 |
31 | {children}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/apps/www/app/auth/google/route.ts:
--------------------------------------------------------------------------------
1 | import { generateCodeVerifier, generateState } from "arctic";
2 | import { cookies } from "next/headers";
3 | import { google } from "@/lib/oauth";
4 |
5 | export async function GET() {
6 | const state = generateState();
7 | const codeVerifier = generateCodeVerifier();
8 | const scopes = ["openid", "profile", "email"];
9 | const url = google.createAuthorizationURL(state, codeVerifier, scopes);
10 |
11 | const cookieStore = await cookies();
12 |
13 | cookieStore.set("google_oauth_state", state, {
14 | path: "/",
15 | httpOnly: true,
16 | secure: process.env.NODE_ENV === "production",
17 | maxAge: 60 * 10,
18 | sameSite: "lax",
19 | });
20 |
21 | cookieStore.set("google_code_verifier", codeVerifier, {
22 | path: "/",
23 | httpOnly: true,
24 | secure: process.env.NODE_ENV === "production",
25 | maxAge: 60 * 10,
26 | sameSite: "lax",
27 | });
28 |
29 | return new Response(null, {
30 | status: 302,
31 | headers: {
32 | Location: url.toString(),
33 | },
34 | });
35 | }
36 |
--------------------------------------------------------------------------------
/packages/ui/src/components/input.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@umamin/ui/lib/utils";
2 | import type * as React from "react";
3 |
4 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
5 | return (
6 |
17 | );
18 | }
19 |
20 | export { Input };
21 |
--------------------------------------------------------------------------------
/apps/www/lib/avatar.ts:
--------------------------------------------------------------------------------
1 | import { createHash } from "node:crypto";
2 |
3 | const GRAVATAR_BASE_URL = "https://www.gravatar.com/avatar";
4 | const GRAVATAR_SIZE = "256";
5 | const GRAVATAR_RATING = "pg";
6 | const GRAVATAR_DEFAULT = "mp";
7 | const GRAVATAR_PREVIEW_DEFAULT = "404";
8 |
9 | export function normaliseEmailForGravatar(email: string): string {
10 | return email.trim().toLowerCase();
11 | }
12 |
13 | export function hashEmailForGravatar(email: string): string {
14 | return createHash("md5").update(email).digest("hex");
15 | }
16 |
17 | export function buildGravatarUrl(hash: string, fallback: "mp" | "404" = "mp") {
18 | const params = new URLSearchParams({
19 | s: GRAVATAR_SIZE,
20 | r: GRAVATAR_RATING,
21 | d: fallback,
22 | });
23 |
24 | return `${GRAVATAR_BASE_URL}/${hash}?${params.toString()}`;
25 | }
26 |
27 | export function getGravatarPreviewUrl(hash: string) {
28 | return buildGravatarUrl(hash, GRAVATAR_PREVIEW_DEFAULT);
29 | }
30 |
31 | export function getGravatarFinalUrl(hash: string) {
32 | return buildGravatarUrl(hash, GRAVATAR_DEFAULT);
33 | }
34 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/components/received/received-message-card-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@umamin/ui/components/skeleton";
2 | import { Icons } from "@/lib/icons";
3 |
4 | export function ReceivedMessageCardSkeleton() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-turborepo",
3 | "private": true,
4 | "scripts": {
5 | "build": "turbo run build",
6 | "dev": "turbo run dev",
7 | "clean": "turbo clean && rm -rf .turbo node_modules",
8 | "ui:add:www": "pnpm --filter=www ui:add",
9 | "db:generate": "pnpm --filter=@umamin/db generate",
10 | "db:migrate": "pnpm --filter=@umamin/db migrate",
11 | "db:studio": "pnpm --filter=@umamin/db studio",
12 | "db:seed": "pnpm --filter=@umamin/db seed",
13 | "aes:generate": "pnpm --filter=@umamin/encryption generate",
14 | "lint": "turbo run lint",
15 | "check-types": "turbo run check-types",
16 | "format-and-lint": "biome check .",
17 | "format-and-lint:fix": "biome check . --write"
18 | },
19 | "devDependencies": {
20 | "@biomejs/biome": "2.3.8",
21 | "@types/node": "^22.19.3",
22 | "lefthook": "^2.0.12",
23 | "turbo": "^2.6.3",
24 | "typescript": "5.9.3"
25 | },
26 | "packageManager": "pnpm@10.26.0",
27 | "engines": {
28 | "node": ">=22"
29 | },
30 | "pnpm": {
31 | "onlyBuiltDependencies": [
32 | "lefthook"
33 | ]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/apps/www/components/grid-pattern.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@umamin/ui/lib/utils";
2 | import { useId } from "react";
3 |
4 | export function GridPattern() {
5 | const id = useId();
6 |
7 | return (
8 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/apps/www/app/providers.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ProgressProvider } from "@bprogress/next/app";
4 | import { QueryClientProvider } from "@tanstack/react-query";
5 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
6 | import { Toaster } from "@umamin/ui/components/sonner";
7 | import { ThemeProvider } from "next-themes";
8 | import { getQueryClient } from "@/lib/get-query-client";
9 |
10 | export default function Providers({ children }: { children: React.ReactNode }) {
11 | const queryClient = getQueryClient();
12 |
13 | return (
14 |
20 |
26 |
27 | {children}
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/apps/www/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from "next";
2 |
3 | export default function sitemap(): MetadataRoute.Sitemap {
4 | const currentDate = new Date();
5 | const base = "https://www.umamin.link";
6 |
7 | return [
8 | {
9 | url: `${base}`,
10 | lastModified: currentDate,
11 | changeFrequency: "monthly",
12 | priority: 1.0,
13 | },
14 | {
15 | url: `${base}/login`,
16 | lastModified: currentDate,
17 | changeFrequency: "yearly",
18 | priority: 0.6,
19 | },
20 | {
21 | url: `${base}/register`,
22 | lastModified: currentDate,
23 | changeFrequency: "yearly",
24 | priority: 0.6,
25 | },
26 | {
27 | url: `${base}/notes`,
28 | lastModified: currentDate,
29 | changeFrequency: "daily",
30 | priority: 0.8,
31 | },
32 | {
33 | url: `${base}/privacy`,
34 | lastModified: currentDate,
35 | changeFrequency: "yearly",
36 | priority: 0.3,
37 | },
38 | {
39 | url: `${base}/terms`,
40 | lastModified: currentDate,
41 | changeFrequency: "yearly",
42 | priority: 0.3,
43 | },
44 | ];
45 | }
46 |
--------------------------------------------------------------------------------
/packages/db/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@umamin/db",
3 | "private": true,
4 | "version": "0.1.0",
5 | "exports": {
6 | ".": {
7 | "types": "./src/index.ts",
8 | "import": "./dist/index.mjs"
9 | },
10 | "./schema/*": {
11 | "types": "./src/schema/*.ts",
12 | "import": "./src/schema/*.ts"
13 | }
14 | },
15 | "scripts": {
16 | "dev": "tsdown --watch ./src & turso dev --db-file local.db",
17 | "build": "tsdown",
18 | "check-types": "tsc --noEmit",
19 | "generate": "drizzle-kit generate",
20 | "migrate": "drizzle-kit migrate",
21 | "studio": "drizzle-kit studio",
22 | "seed": "tsx --env-file=.env ./src/seed.ts",
23 | "clean": "rm -rf .turbo node_modules"
24 | },
25 | "dependencies": {
26 | "@libsql/client": "^0.15.15",
27 | "drizzle-orm": "^0.45.1",
28 | "nanoid": "^5.1.6"
29 | },
30 | "devDependencies": {
31 | "@faker-js/faker": "^10.1.0",
32 | "@types/node": "^22.19.3",
33 | "@umamin/encryption": "workspace:*",
34 | "drizzle-kit": "^0.31.8",
35 | "drizzle-seed": "^0.3.1",
36 | "tsdown": "^0.18.0",
37 | "tsx": "^4.21.0",
38 | "typescript": "^5.9.3"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/db/src/schema/note.ts:
--------------------------------------------------------------------------------
1 | import { relations, sql } from "drizzle-orm";
2 | import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3 | import { nanoid } from "nanoid";
4 |
5 | import { userTable } from "./user";
6 |
7 | export const noteTable = sqliteTable(
8 | "note",
9 | {
10 | id: text("id")
11 | .primaryKey()
12 | .$defaultFn(() => nanoid()),
13 | userId: text("user_id").unique().notNull(),
14 | content: text("content").notNull(),
15 | isAnonymous: integer("is_anonymous", { mode: "boolean" }).notNull(),
16 | createdAt: integer("created_at", { mode: "timestamp" })
17 | .notNull()
18 | .default(sql`(unixepoch())`),
19 | updatedAt: integer("updated_at", { mode: "timestamp" }).$onUpdate(
20 | () => new Date(),
21 | ),
22 | },
23 | (t) => [index("updated_at_id_idx").on(t.updatedAt, t.id)],
24 | );
25 |
26 | export const noteRelations = relations(noteTable, ({ one }) => ({
27 | user: one(userTable, {
28 | fields: [noteTable.userId],
29 | references: [userTable.id],
30 | }),
31 | }));
32 |
33 | export type InsertNote = typeof noteTable.$inferInsert;
34 | export type SelectNote = typeof noteTable.$inferSelect;
35 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/components/sent/sent-message-card-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardFooter,
5 | CardHeader,
6 | } from "@umamin/ui/components/card";
7 | import { Skeleton } from "@umamin/ui/components/skeleton";
8 | import { CircleUserIcon } from "lucide-react";
9 | import { ChatListSkeleton } from "@/components/skeleton/chat-list-skeleton";
10 |
11 | export function SentMessageCardSkeleton() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
umamin
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/apps/www/components/menu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DropdownMenu,
3 | DropdownMenuContent,
4 | DropdownMenuItem,
5 | DropdownMenuSeparator,
6 | DropdownMenuTrigger,
7 | } from "@umamin/ui/components/dropdown-menu";
8 | import React from "react";
9 | import { Icons } from "@/lib/icons";
10 |
11 | export type MenuItems = {
12 | title: string;
13 | onClick: () => void;
14 | className?: string;
15 | disabled?: boolean;
16 | }[];
17 |
18 | export const Menu = ({ menuItems }: { menuItems: MenuItems }) => {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | {menuItems.map((item, i) => (
26 |
27 |
32 | {item.title}
33 |
34 | {i + 1 !== menuItems.length && }
35 |
36 | ))}
37 |
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/apps/www/components/copy-link.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Badge } from "@umamin/ui/components/badge";
4 | import { Skeleton } from "@umamin/ui/components/skeleton";
5 | import { Link2Icon } from "lucide-react";
6 | import { toast } from "sonner";
7 |
8 | const onCopy = (url: string) => {
9 | if (typeof window !== "undefined") {
10 | navigator.clipboard.writeText(url);
11 | toast.success("Copied to clipboard");
12 | }
13 | };
14 |
15 | export default function CopyLink({ username }: { username: string }) {
16 | const url =
17 | typeof window !== "undefined"
18 | ? `${window.location.origin}/to/${username}`
19 | : "";
20 |
21 | if (!url) {
22 | return (
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | return (
31 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/apps/www/app/to/[username]/components/chat-form-skeleton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@umamin/ui/components/button";
4 | import { Textarea } from "@umamin/ui/components/textarea";
5 | import { cn } from "@umamin/ui/lib/utils";
6 | import { SendIcon } from "lucide-react";
7 | import { ChatListSkeleton } from "@/components/skeleton/chat-list-skeleton";
8 |
9 | export function ChatFormSkeleton() {
10 | return (
11 |
17 |
18 |
19 |
20 |
21 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/apps/www/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@umamin/ui/components/button";
2 | import Link from "next/link";
3 | import { Demo } from "@/components/demo";
4 |
5 | export default async function Home() {
6 | return (
7 |
8 |
9 |
10 | The Platform for Anonymity
11 |
12 |
13 |
14 | A community focused open-source platform for sending and receiving
15 | encrypted anonymous messages.
16 |
17 |
18 |
21 |
22 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/packages/ui/src/components/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as SwitchPrimitive from "@radix-ui/react-switch";
4 | import { cn } from "@umamin/ui/lib/utils";
5 | import type * as React from "react";
6 |
7 | function Switch({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 |
26 |
27 | );
28 | }
29 |
30 | export { Switch };
31 |
--------------------------------------------------------------------------------
/apps/www/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/ui/src/components/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
4 | import { cn } from "@umamin/ui/lib/utils";
5 | import type * as React from "react";
6 |
7 | function Avatar({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 | );
21 | }
22 |
23 | function AvatarImage({
24 | className,
25 | ...props
26 | }: React.ComponentProps) {
27 | return (
28 |
33 | );
34 | }
35 |
36 | function AvatarFallback({
37 | className,
38 | ...props
39 | }: React.ComponentProps) {
40 | return (
41 |
49 | );
50 | }
51 |
52 | export { Avatar, AvatarImage, AvatarFallback };
53 |
--------------------------------------------------------------------------------
/apps/www/app/settings/components/settings-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@umamin/ui/components/skeleton";
2 |
3 | export function SettingsSkeleton() {
4 | return (
5 |
6 | {/* Display Name Field */}
7 |
8 | {/* Label */}
9 | {/* Input field */}
10 |
11 |
12 | {/* Username Field */}
13 |
14 | {/* Label */}
15 | {/* Input field */}
16 | {/* Helper text */}
17 |
18 |
19 | {/* Custom Message Field */}
20 |
21 | {/* Label */}
22 | {/* Textarea */}
23 |
24 |
25 | {/* Bio Field */}
26 |
27 | {/* Label */}
28 | {/* Textarea */}
29 |
30 |
31 | {/* Submit Button */}
32 |
33 | {/* Save Changes button */}
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/components/received/received-card.tsx:
--------------------------------------------------------------------------------
1 | import type { SelectMessage } from "@umamin/db/schema/message";
2 | import { cn } from "@umamin/ui/lib/utils";
3 | import { formatDistanceToNow } from "date-fns";
4 | import { ReceivedMessageMenu } from "./received-card-menu";
5 |
6 | export function ReceivedMessageCard({ data }: { data: SelectMessage }) {
7 | return (
8 |
9 |
14 |
15 |
20 |
21 |
22 |
23 | {data.question}
24 |
25 |
26 | {data.content}
27 |
28 |
29 | {formatDistanceToNow(data.createdAt, {
30 | addSuffix: true,
31 | })}
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/apps/www/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@umamin/ui/components/button";
2 | import { cn } from "@umamin/ui/lib/utils";
3 | import Link from "next/link";
4 | import { AnimatedShinyText } from "@/components/animated-shiny-text";
5 |
6 | export default function NotFound() {
7 | return (
8 |
9 |
14 |
15 | Error 404
16 |
17 |
18 |
19 |
20 | Oops, Page Not Found
21 |
22 |
23 |
24 |
25 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/apps/www/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@umamin/ui/components/button";
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuTrigger,
9 | } from "@umamin/ui/components/dropdown-menu";
10 | import { MoonIcon, SunIcon } from "lucide-react";
11 | import { useTheme } from "next-themes";
12 |
13 | export function ThemeToggle() {
14 | const { setTheme } = useTheme();
15 |
16 | return (
17 |
18 |
19 |
24 |
25 |
26 | setTheme("light")}>
27 | Light
28 |
29 | setTheme("dark")}>
30 | Dark
31 |
32 | setTheme("system")}>
33 | System
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/apps/www/app/to/[username]/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@umamin/ui/components/skeleton";
2 | import { LockIcon } from "lucide-react";
3 | import { ChatFormSkeleton } from "./components/chat-form-skeleton";
4 |
5 | export default function Loading() {
6 | return (
7 | <>
8 |
9 |
10 |
11 |
12 | To:
13 |
14 |
15 |
16 |
umamin
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Advanced Encryption Standard
26 |
27 |
28 |
29 | {/* Skeleton for UnauthenticatedDialog - just a placeholder since it's conditional */}
30 |
31 |
32 |
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/apps/www/components/ad-container.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@umamin/ui/lib/utils";
4 | import { useEffect } from "react";
5 |
6 | declare global {
7 | interface Window {
8 | // biome-ignore lint/suspicious/noExplicitAny: google
9 | adsbygoogle: any;
10 | }
11 | }
12 |
13 | type Props = {
14 | slotId: string;
15 | className?: string;
16 | };
17 |
18 | const AdContainer = ({ slotId, className }: Props) => {
19 | useEffect(() => {
20 | try {
21 | if (
22 | process.env.NODE_ENV === "production" &&
23 | typeof window !== "undefined" &&
24 | !window.location.hostname.includes("localhost")
25 | ) {
26 | // biome-ignore lint/suspicious/noAssignInExpressions: google
27 | (window.adsbygoogle = window.adsbygoogle || []).push({});
28 | }
29 | } catch (err) {
30 | console.log(err);
31 | }
32 | }, []);
33 |
34 | return (
35 |
41 |
49 |
50 | );
51 | };
52 |
53 | export default AdContainer;
54 |
--------------------------------------------------------------------------------
/packages/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@umamin/ui",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "private": true,
6 | "dependencies": {
7 | "@radix-ui/react-alert-dialog": "^1.1.15",
8 | "@radix-ui/react-avatar": "^1.1.11",
9 | "@radix-ui/react-collapsible": "^1.1.12",
10 | "@radix-ui/react-dialog": "^1.1.15",
11 | "@radix-ui/react-dropdown-menu": "^2.1.16",
12 | "@radix-ui/react-label": "^2.1.8",
13 | "@radix-ui/react-slot": "^1.2.4",
14 | "@radix-ui/react-switch": "^1.2.6",
15 | "@radix-ui/react-tabs": "^1.1.13",
16 | "class-variance-authority": "^0.7.1",
17 | "clsx": "^2.1.1",
18 | "lucide-react": "^0.561.0",
19 | "next-themes": "^0.4.6",
20 | "sonner": "^2.0.7",
21 | "tailwind-merge": "^3.4.0",
22 | "tw-animate-css": "^1.4.0",
23 | "vaul": "^1.1.2"
24 | },
25 | "devDependencies": {
26 | "@tailwindcss/postcss": "^4.1.18",
27 | "@turbo/gen": "^2.6.3",
28 | "@types/node": "^22.19.3",
29 | "@types/react": "^19.2.7",
30 | "@types/react-dom": "^19.2.3",
31 | "tailwindcss": "^4.1.18",
32 | "typescript": "^5.9.3"
33 | },
34 | "peerDependencies": {
35 | "react": "^19.2.3",
36 | "react-dom": "^19.2.3"
37 | },
38 | "exports": {
39 | "./globals.css": "./src/styles/globals.css",
40 | "./postcss.config": "./postcss.config.mjs",
41 | "./lib/*": "./src/lib/*.ts",
42 | "./components/*": "./src/components/*.tsx",
43 | "./hooks/*": "./src/hooks/*.ts"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/apps/www/app/notes/components/note-card-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader } from "@umamin/ui/components/card";
2 | import { Skeleton } from "@umamin/ui/components/skeleton";
3 |
4 | export function NoteCardSkeleton() {
5 | return (
6 |
7 |
8 |
9 |
10 | {/* Avatar skeleton */}
11 |
12 |
13 |
14 | {/* Display name skeleton */}
15 |
16 | {/* Username skeleton */}
17 |
18 |
19 |
20 |
21 |
22 | {/* Timestamp skeleton */}
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {/* Content skeleton - multiple lines with varying widths */}
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { redirect } from "next/navigation";
3 | import { Suspense } from "react";
4 | import { UserCardSkeleton } from "@/components/skeleton/user-card-skeleton";
5 | import { getSession } from "@/lib/auth";
6 | import { CurrentUserCard } from "./components/current-user-card";
7 | import { InboxTabs } from "./components/inbox-tabs";
8 |
9 | export const metadata: Metadata = {
10 | title: "Umamin — Inbox",
11 | description:
12 | "Manage your received messages securely on Umamin. View, reply, and organize your inbox.",
13 | robots: {
14 | index: false,
15 | follow: false,
16 | },
17 | openGraph: {
18 | type: "website",
19 | title: "Umamin — Inbox",
20 | description:
21 | "Manage your received messages securely on Umamin. View, reply, and organize your inbox.",
22 | url: "https://www.umamin.link/inbox",
23 | },
24 | twitter: {
25 | card: "summary_large_image",
26 | title: "Umamin — Inbox",
27 | description:
28 | "Manage your received messages securely on Umamin. View, reply, and organize your inbox.",
29 | },
30 | };
31 |
32 | export default async function InboxPage() {
33 | const { session } = await getSession();
34 |
35 | if (!session) {
36 | redirect("/login");
37 | }
38 |
39 | return (
40 |
41 | }>
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/apps/www/lib/get-query-client.ts:
--------------------------------------------------------------------------------
1 | import {
2 | defaultShouldDehydrateQuery,
3 | isServer,
4 | QueryClient,
5 | } from "@tanstack/react-query";
6 |
7 | function makeQueryClient() {
8 | return new QueryClient({
9 | defaultOptions: {
10 | queries: {
11 | staleTime: 60 * 1000,
12 | },
13 | dehydrate: {
14 | // include pending queries in dehydration
15 | shouldDehydrateQuery: (query) =>
16 | defaultShouldDehydrateQuery(query) ||
17 | query.state.status === "pending",
18 | shouldRedactErrors: () => {
19 | // We should not catch Next.js server errors
20 | // as that's how Next.js detects dynamic pages
21 | // so we cannot redact them.
22 | // Next.js also automatically redacts errors for us
23 | // with better digests.
24 | return false;
25 | },
26 | },
27 | },
28 | });
29 | }
30 |
31 | let browserQueryClient: QueryClient | undefined;
32 |
33 | export function getQueryClient() {
34 | if (isServer) {
35 | // Server: always make a new query client
36 | return makeQueryClient();
37 | } else {
38 | // Browser: make a new query client if we don't already have one
39 | // This is very important, so we don't re-make a new client if React
40 | // suspends during the initial render. This may not be needed if we
41 | // have a suspense boundary BELOW the creation of the query client
42 | if (!browserQueryClient) browserQueryClient = makeQueryClient();
43 | return browserQueryClient;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/apps/www/app/api/users/[username]/route.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@umamin/db";
2 | import { userTable } from "@umamin/db/schema/user";
3 | import { eq } from "drizzle-orm";
4 | import { cacheLife, cacheTag } from "next/cache";
5 |
6 | const revalidate = 604800; // 7 days
7 |
8 | export async function GET(
9 | _req: Request,
10 | { params }: { params: Promise<{ username: string }> },
11 | ) {
12 | try {
13 | const { username } = await params;
14 |
15 | const getCached = async () => {
16 | "use cache";
17 | cacheTag(`user:${username}`);
18 | cacheLife({ revalidate });
19 |
20 | const [user] = await db
21 | .select({
22 | id: userTable.id,
23 | username: userTable.username,
24 | displayName: userTable.displayName,
25 | imageUrl: userTable.imageUrl,
26 | bio: userTable.bio,
27 | question: userTable.question,
28 | quietMode: userTable.quietMode,
29 | createdAt: userTable.createdAt,
30 | updatedAt: userTable.updatedAt,
31 | })
32 | .from(userTable)
33 | .where(eq(userTable.username, username))
34 | .limit(1);
35 | return user;
36 | };
37 |
38 | const user = await getCached();
39 |
40 | if (!user) {
41 | return Response.json({ error: "User not found" }, { status: 404 });
42 | }
43 |
44 | return Response.json(user);
45 | } catch (error) {
46 | console.error("Error fetching user:", error);
47 | return Response.json({ error: "Internal server error" }, { status: 500 });
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/apps/www/components/skeleton/user-card-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback } from "@umamin/ui/components/avatar";
2 | import { Skeleton } from "@umamin/ui/components/skeleton";
3 | import { CalendarDaysIcon, Link2Icon } from "lucide-react";
4 |
5 | export function UserCardSkeleton() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | Joined
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/packages/db/src/schema/message.ts:
--------------------------------------------------------------------------------
1 | import { relations, sql } from "drizzle-orm";
2 | import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3 | import { nanoid } from "nanoid";
4 |
5 | import { userTable } from "./user";
6 |
7 | export const messageTable = sqliteTable(
8 | "message",
9 | {
10 | id: text("id")
11 | .primaryKey()
12 | .$defaultFn(() => nanoid()),
13 | question: text("question").notNull(),
14 | content: text("content").notNull(),
15 | reply: text("reply"),
16 | receiverId: text("receiver_id").notNull(),
17 | senderId: text("sender_id"),
18 | createdAt: integer("created_at", { mode: "timestamp" })
19 | .notNull()
20 | .default(sql`(unixepoch())`),
21 | updatedAt: integer("updated_at", { mode: "timestamp" }).$onUpdate(
22 | () => new Date(),
23 | ),
24 | },
25 | (t) => [
26 | index("receiver_id_created_at_id_idx").on(t.receiverId, t.createdAt, t.id),
27 | index("sender_id_created_at_id_idx").on(t.senderId, t.createdAt, t.id),
28 | ],
29 | );
30 |
31 | export const messageRelations = relations(messageTable, ({ one }) => ({
32 | receiver: one(userTable, {
33 | fields: [messageTable.receiverId],
34 | references: [userTable.id],
35 | relationName: "receiver",
36 | }),
37 | sender: one(userTable, {
38 | fields: [messageTable.senderId],
39 | references: [userTable.id],
40 | relationName: "sender",
41 | }),
42 | }));
43 |
44 | export type InsertMessage = typeof messageTable.$inferInsert;
45 | export type SelectMessage = typeof messageTable.$inferSelect;
46 |
--------------------------------------------------------------------------------
/apps/www/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/apps/www/components/skeleton/chat-list-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback } from "@umamin/ui/components/avatar";
2 | import { Skeleton } from "@umamin/ui/components/skeleton";
3 | import { ScanFaceIcon } from "lucide-react";
4 |
5 | export const ChatListSkeleton = () => {
6 | return (
7 |
8 | {/* Initial message */}
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {/* Reply message */}
22 |
23 |
24 |
25 |
26 |
27 | {/* Response message */}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/apps/www/components/unauthenticated-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | AlertDialog,
5 | AlertDialogAction,
6 | AlertDialogCancel,
7 | AlertDialogContent,
8 | AlertDialogDescription,
9 | AlertDialogFooter,
10 | AlertDialogHeader,
11 | AlertDialogTitle,
12 | } from "@umamin/ui/components/alert-dialog";
13 | import { TriangleAlertIcon } from "lucide-react";
14 | import Link from "next/link";
15 | import { useState } from "react";
16 |
17 | export default function UnauthenticatedDialog({
18 | isLoggedIn,
19 | }: {
20 | isLoggedIn: boolean;
21 | }) {
22 | const [open, onOpenChange] = useState(!isLoggedIn);
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 | You are not logged in
31 |
32 |
33 | Messages sent will not be saved in your inbox and you won't be
34 | able to see the reply from this user. Do you still want to continue?
35 |
36 |
37 |
38 | Continue
39 |
40 | Login
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/apps/www/types/user.ts:
--------------------------------------------------------------------------------
1 | import type { SelectAccount, SelectUser } from "@umamin/db/schema/user";
2 | import * as z from "zod";
3 |
4 | export type UserWithAccount = SelectUser & { account: SelectAccount | null };
5 |
6 | export type PublicUser = Omit;
7 |
8 | export const generalSettingsSchema = z.object({
9 | question: z
10 | .string()
11 | .min(1, { error: "Custom message must be at least 1 character." })
12 | .max(150, {
13 | error: "Custom message must not be longer than 150 characters.",
14 | }),
15 | bio: z
16 | .string()
17 | .max(150, { error: "Bio must not be longer than 150 characters." }),
18 | displayName: z
19 | .string()
20 | .max(20, { error: "Display name must not exceed 20 characters." }),
21 | username: z
22 | .string()
23 | .min(5, { error: "Username must be at least 5 characters." })
24 | .max(20, { error: "Username must not exceed 20 characters." })
25 | .refine((v) => /^[a-zA-Z0-9_-]+$/.test(v), {
26 | error: "Username must be alphanumeric with no spaces.",
27 | }),
28 | });
29 |
30 | const passwordSchema = z
31 | .string()
32 | .min(5, { error: "Password must be at least 5 characters" })
33 | .max(128, { error: "Password must not exceed 128 characters" });
34 |
35 | export const passwordFormSchema = z
36 | .object({
37 | currentPassword: z.string(),
38 | newPassword: passwordSchema,
39 | confirmPassword: z.string(),
40 | })
41 | .refine((v) => v.newPassword === v.confirmPassword, {
42 | message: "Passwords do not match",
43 | path: ["confirmPassword"],
44 | });
45 |
--------------------------------------------------------------------------------
/apps/www/components/chat-list.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Avatar,
3 | AvatarFallback,
4 | AvatarImage,
5 | } from "@umamin/ui/components/avatar";
6 | import { ScanFaceIcon } from "lucide-react";
7 |
8 | type Props = {
9 | imageUrl?: string | null;
10 | question: string;
11 | reply?: string;
12 | response?: string;
13 | };
14 |
15 | export const ChatList = ({ imageUrl, question, reply, response }: Props) => {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | {question}
27 |
28 |
29 |
30 | {reply && (
31 |
32 | {reply}
33 |
34 | )}
35 |
36 | {response && (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {response}
46 |
47 |
48 | )}
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/components/inbox-tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Tabs, TabsList, TabsTrigger } from "@umamin/ui/components/tabs";
4 | import dynamic from "next/dynamic";
5 | import { Activity, useState } from "react";
6 | import { ReceivedMessages } from "./received/received-messages";
7 | import { SentMessages } from "./sent/sent-messages";
8 |
9 | const AdContainer = dynamic(() => import("@/components/ad-container"));
10 |
11 | export function InboxTabs() {
12 | const [selected, setSelected] = useState<"received" | "sent">("received");
13 |
14 | return (
15 | setSelected(val as "received" | "sent")}
18 | className="w-full mt-4"
19 | >
20 |
21 |
25 | Received
26 |
27 |
31 | Sent
32 |
33 |
34 |
35 | {/* v2-inbox */}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: ["main", "dev"]
6 | pull_request:
7 | types: [opened, synchronize]
8 |
9 | jobs:
10 | build:
11 | timeout-minutes: 15
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | node-version: [22]
16 | env:
17 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
18 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
19 |
20 | steps:
21 | - uses: actions/checkout@v4
22 |
23 | - name: Cache turbo build setup
24 | uses: actions/cache@v4
25 | with:
26 | path: .turbo
27 | key: ${{ runner.os }}-turbo-${{ github.sha }}
28 | restore-keys: |
29 | ${{ runner.os }}-turbo-
30 |
31 | - name: Install pnpm
32 | uses: pnpm/action-setup@v4
33 | with:
34 | version: 10.26.0
35 |
36 | - name: Use Node.js ${{ matrix.node-version }}
37 | uses: actions/setup-node@v4
38 | with:
39 | node-version: ${{ matrix.node-version }}
40 | cache: "pnpm"
41 |
42 | - name: Install dependencies
43 | run: pnpm install --frozen-lockfile
44 |
45 | - name: Build project
46 | env:
47 | TURSO_CONNECTION_URL: ${{ secrets.TURSO_CONNECTION_URL }}
48 | run: pnpm build
49 |
50 | quality:
51 | runs-on: ubuntu-latest
52 | permissions:
53 | contents: read
54 |
55 | steps:
56 | - name: Checkout
57 | uses: actions/checkout@v4
58 | with:
59 | persist-credentials: false
60 |
61 | - name: Setup Biome
62 | uses: biomejs/setup-biome@v2
63 | with:
64 | version: 2.3.8
65 |
66 | - name: Run Biome
67 | run: biome ci .
68 |
--------------------------------------------------------------------------------
/apps/www/app/login/components/login-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@umamin/ui/components/button";
4 | import { Input } from "@umamin/ui/components/input";
5 | import { Label } from "@umamin/ui/components/label";
6 | import { Loader2Icon } from "lucide-react";
7 | import Link from "next/link";
8 | import { useActionState } from "react";
9 | import { login } from "@/lib/auth";
10 |
11 | export function LoginForm() {
12 | const [state, formAction, pending] = useActionState(login, { error: "" });
13 |
14 | return (
15 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/apps/www/app/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@umamin/ui/components/button";
4 | import { cn } from "@umamin/ui/lib/utils";
5 | import type NextError from "next/error";
6 | import Link from "next/link";
7 | import { AnimatedShinyText } from "@/components/animated-shiny-text";
8 |
9 | export default function GlobalError({
10 | reset,
11 | }: {
12 | error: NextError & { digest?: string };
13 | reset: () => void;
14 | }) {
15 | return (
16 |
17 |
22 |
23 | Error
24 |
25 |
26 |
27 |
28 | Something went wrong!
29 |
30 |
31 |
32 |
33 |
36 |
37 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | Our goal is to ensure that security issues are addressed promptly and securely, minimizing the risk to our users.
4 |
5 | ## Reporting Security Vulnerabilities
6 |
7 | If you believe you have found a security vulnerability in Umamin, please follow these steps:
8 |
9 | 1. **Do Not Open a Public Issue:**
10 | - Do not open a public issue on the repository to report the vulnerability. Public issues can inadvertently expose sensitive details to the broader community before we have a chance to address them.
11 |
12 | 2. **Do Not Submit a Pull Request:**
13 | - Do not submit a pull request with a fix for the vulnerability until you have consulted with us. This ensures we can review and address the issue appropriately before making any changes public.
14 |
15 | 3. **Contact Us Directly:**
16 | - Email us at [umamin@omsimos.com](mailto:umamin.link@gmail.com) or [joshxfi.dev@gmail.com](mailto:joshxfi.dev@gmail.com) with the details of the vulnerability. Please include as much information as possible to help us understand and reproduce the issue.
17 |
18 | 4. **Open a Draft Security Advisory:**
19 | - If you prefer to use GitHub for reporting, open a draft security advisory from the issue template "Report a security vulnerability". If you've already fixed the vulnerability, fill out the draft security advisory and then publish it.
20 |
21 | ## Acknowledgement and Response
22 |
23 | - We will acknowledge your report within 5 days.
24 | - We may reach out to you for further information or clarification during this process.
25 | - Once the vulnerability has been addressed, we will disclose the issue publicly (and credit you with your consent).
26 |
27 | We appreciate your help in keeping Umamin secure and thank you for following these guidelines to report security vulnerabilities responsibly.
28 |
--------------------------------------------------------------------------------
/packages/ui/src/components/badge.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { cn } from "@umamin/ui/lib/utils";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import type * as React from "react";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
15 | destructive:
16 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
17 | outline:
18 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
19 | },
20 | },
21 | defaultVariants: {
22 | variant: "default",
23 | },
24 | },
25 | );
26 |
27 | function Badge({
28 | className,
29 | variant,
30 | asChild = false,
31 | ...props
32 | }: React.ComponentProps<"span"> &
33 | VariantProps & { asChild?: boolean }) {
34 | const Comp = asChild ? Slot : "span";
35 |
36 | return (
37 |
42 | );
43 | }
44 |
45 | export { Badge, badgeVariants };
46 |
--------------------------------------------------------------------------------
/apps/www/proxy.ts:
--------------------------------------------------------------------------------
1 | import type { NextRequest } from "next/server";
2 | import { NextResponse } from "next/server";
3 |
4 | export async function proxy(request: NextRequest): Promise {
5 | if (request.method === "GET") {
6 | const response = NextResponse.next();
7 | const token = request.cookies.get("session")?.value ?? null;
8 | if (token !== null) {
9 | // Only extend cookie expiration on GET requests since we can be sure
10 | // a new session wasn't set when handling the request.
11 | response.cookies.set("session", token, {
12 | path: "/",
13 | maxAge: 60 * 60 * 24 * 30,
14 | sameSite: "lax",
15 | httpOnly: true,
16 | secure: process.env.NODE_ENV === "production",
17 | });
18 | }
19 | return response;
20 | }
21 |
22 | const originHeader = request.headers.get("Origin");
23 | // NOTE: You may need to use `X-Forwarded-Host` instead
24 | const hostHeader = request.headers.get("Host");
25 | if (originHeader === null || hostHeader === null) {
26 | return new NextResponse(null, {
27 | status: 403,
28 | });
29 | }
30 | let origin: URL;
31 | try {
32 | origin = new URL(originHeader);
33 | } catch {
34 | return new NextResponse(null, {
35 | status: 403,
36 | });
37 | }
38 | if (origin.host !== hostHeader) {
39 | return new NextResponse(null, {
40 | status: 403,
41 | });
42 | }
43 | return NextResponse.next();
44 | }
45 |
46 | export const config = {
47 | matcher: [
48 | /*
49 | * Match all request paths except for the ones starting with:
50 | * - api (API routes)
51 | * - _next/static (static files)
52 | * - _next/image (image optimization files)
53 | * - favicon.ico, sitemap.xml, robots.txt (metadata files)
54 | */
55 | "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
56 | ],
57 | };
58 |
--------------------------------------------------------------------------------
/packages/ui/src/components/alert.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@umamin/ui/lib/utils";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import type * as React from "react";
4 |
5 | const alertVariants = cva(
6 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
7 | {
8 | variants: {
9 | variant: {
10 | default: "bg-card text-card-foreground",
11 | destructive:
12 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
13 | },
14 | },
15 | defaultVariants: {
16 | variant: "default",
17 | },
18 | },
19 | );
20 |
21 | function Alert({
22 | className,
23 | variant,
24 | ...props
25 | }: React.ComponentProps<"div"> & VariantProps) {
26 | return (
27 |
33 | );
34 | }
35 |
36 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
37 | return (
38 |
46 | );
47 | }
48 |
49 | function AlertDescription({
50 | className,
51 | ...props
52 | }: React.ComponentProps<"div">) {
53 | return (
54 |
62 | );
63 | }
64 |
65 | export { Alert, AlertTitle, AlertDescription };
66 |
--------------------------------------------------------------------------------
/apps/www/components/browser-warning.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Alert,
5 | AlertDescription,
6 | AlertTitle,
7 | } from "@umamin/ui/components/alert";
8 | import {
9 | AlertDialog,
10 | AlertDialogCancel,
11 | AlertDialogContent,
12 | AlertDialogDescription,
13 | AlertDialogFooter,
14 | AlertDialogHeader,
15 | AlertDialogTitle,
16 | } from "@umamin/ui/components/alert-dialog";
17 | import { TriangleAlertIcon } from "lucide-react";
18 | import { useState } from "react";
19 |
20 | export default function BrowserWarning() {
21 | const isFbAgent = /FBAN|FBAV/i.test(navigator.userAgent);
22 | const [open, setOpen] = useState(isFbAgent);
23 |
24 | return (
25 | <>
26 |
27 |
28 |
29 |
30 |
31 | In-app browser detected
32 |
33 |
34 | Facebook in-app browser detected, click the "..." menu
35 | and choose "Open in External Browser"
36 |
37 |
38 |
39 | Continue
40 |
41 |
42 |
43 |
44 | {isFbAgent && (
45 |
46 |
47 | Warning
48 |
49 | Facebook in-app browser detected, please use an external browser to
50 | avoid running into issues.
51 |
52 |
53 | )}
54 | >
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/apps/www/app/global-error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@umamin/ui/components/button";
4 | import { cn } from "@umamin/ui/lib/utils";
5 | import type NextError from "next/error";
6 | import Link from "next/link";
7 | import { AnimatedShinyText } from "@/components/animated-shiny-text";
8 |
9 | export default function GlobalError({
10 | reset,
11 | }: {
12 | error: NextError & { digest?: string };
13 | reset: () => void;
14 | }) {
15 | return (
16 |
17 |
18 |
19 |
24 |
25 | Error
26 |
27 |
28 |
29 |
30 | Something went wrong!
31 |
32 |
33 |
34 |
35 |
38 |
39 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/packages/db/migrations/0000_fresh_shard.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE `oauth_account` (
2 | `provider_user_id` text PRIMARY KEY NOT NULL,
3 | `email` text NOT NULL,
4 | `picture` text NOT NULL,
5 | `user_id` text NOT NULL,
6 | `provider_id` text NOT NULL,
7 | `created_at` integer DEFAULT (unixepoch()) NOT NULL,
8 | `updated_at` integer
9 | );
10 | --> statement-breakpoint
11 | CREATE TABLE `session` (
12 | `id` text PRIMARY KEY NOT NULL,
13 | `user_id` text NOT NULL,
14 | `expires_at` integer NOT NULL,
15 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
16 | );
17 | --> statement-breakpoint
18 | CREATE TABLE `user` (
19 | `id` text PRIMARY KEY NOT NULL,
20 | `display_name` text,
21 | `username` text NOT NULL,
22 | `password_hash` text,
23 | `bio` text,
24 | `image_url` text,
25 | `quiet_mode` integer DEFAULT false NOT NULL,
26 | `question` text DEFAULT 'Send me an anonymous message!' NOT NULL,
27 | `created_at` integer DEFAULT (unixepoch()) NOT NULL,
28 | `updated_at` integer
29 | );
30 | --> statement-breakpoint
31 | CREATE TABLE `note` (
32 | `id` text PRIMARY KEY NOT NULL,
33 | `user_id` text NOT NULL,
34 | `content` text NOT NULL,
35 | `is_anonymous` integer NOT NULL,
36 | `created_at` integer DEFAULT (unixepoch()) NOT NULL,
37 | `updated_at` integer
38 | );
39 | --> statement-breakpoint
40 | CREATE TABLE `message` (
41 | `id` text PRIMARY KEY NOT NULL,
42 | `question` text NOT NULL,
43 | `content` text NOT NULL,
44 | `reply` text,
45 | `receiver_id` text NOT NULL,
46 | `sender_id` text,
47 | `created_at` integer DEFAULT (unixepoch()) NOT NULL,
48 | `updated_at` integer
49 | );
50 | --> statement-breakpoint
51 | CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint
52 | CREATE UNIQUE INDEX `note_user_id_unique` ON `note` (`user_id`);--> statement-breakpoint
53 | CREATE INDEX `updated_at_id_idx` ON `note` (`updated_at`,`id`);--> statement-breakpoint
54 | CREATE INDEX `receiver_id_created_at_id_idx` ON `message` (`receiver_id`,`created_at`,`id`);--> statement-breakpoint
55 | CREATE INDEX `sender_id_created_at_id_idx` ON `message` (`sender_id`,`created_at`,`id`);
--------------------------------------------------------------------------------
/apps/www/app/login/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import dynamic from "next/dynamic";
3 | import Link from "next/link";
4 | import { redirect } from "next/navigation";
5 | import { getSession } from "@/lib/auth";
6 | import { LoginForm } from "./components/login-form";
7 |
8 | const BrowserWarning = dynamic(() => import("@/components/browser-warning"));
9 |
10 | export const metadata: Metadata = {
11 | title: "Umamin — Login",
12 | description:
13 | "Log in to Umamin to send and receive encrypted anonymous messages. Secure your privacy and communicate freely.",
14 | keywords: [
15 | "Umamin login",
16 | "anonymous messaging login",
17 | "encrypted messages login",
18 | ],
19 | robots: {
20 | index: true,
21 | follow: true,
22 | },
23 | openGraph: {
24 | type: "website",
25 | title: "Umamin — Login",
26 | description:
27 | "Log in to Umamin to send and receive encrypted anonymous messages. Secure your privacy and communicate freely.",
28 | url: "https://www.umamin.link/login",
29 | },
30 | twitter: {
31 | card: "summary_large_image",
32 | title: "Umamin — Login",
33 | description:
34 | "Log in to Umamin to send and receive encrypted anonymous messages. Secure your privacy and communicate freely.",
35 | },
36 | };
37 |
38 | export default async function Login() {
39 | const { session } = await getSession();
40 |
41 | if (session) {
42 | redirect("/inbox");
43 | }
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 | Umamin Account
52 |
53 |
54 | Proceed with your Umamin profile
55 |
56 |
57 |
58 |
59 |
60 |
61 | Don't have an account?{" "}
62 |
63 | Sign up
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/apps/www/components/share-link-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@umamin/ui/components/button";
4 | import {
5 | Dialog,
6 | DialogClose,
7 | DialogContent,
8 | DialogDescription,
9 | DialogFooter,
10 | DialogHeader,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from "@umamin/ui/components/dialog";
14 | import { Input } from "@umamin/ui/components/input";
15 | import { Label } from "@umamin/ui/components/label";
16 | import { CopyIcon, LinkIcon } from "lucide-react";
17 | import { toast } from "sonner";
18 |
19 | const onCopy = (url: string) => {
20 | if (typeof window !== "undefined") {
21 | navigator.clipboard.writeText(url);
22 | toast.success("Copied to clipboard");
23 | }
24 | };
25 |
26 | export function ShareLinkDialog({ username }: { username: string }) {
27 | const url =
28 | typeof window !== "undefined"
29 | ? `${window.location.origin}/to/${username}`
30 | : "";
31 |
32 | return (
33 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/packages/ui/src/components/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as TabsPrimitive from "@radix-ui/react-tabs";
4 | import { cn } from "@umamin/ui/lib/utils";
5 | import type * as React from "react";
6 |
7 | function Tabs({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
17 | );
18 | }
19 |
20 | function TabsList({
21 | className,
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
33 | );
34 | }
35 |
36 | function TabsTrigger({
37 | className,
38 | ...props
39 | }: React.ComponentProps) {
40 | return (
41 |
49 | );
50 | }
51 |
52 | function TabsContent({
53 | className,
54 | ...props
55 | }: React.ComponentProps) {
56 | return (
57 |
62 | );
63 | }
64 |
65 | export { Tabs, TabsList, TabsTrigger, TabsContent };
66 |
--------------------------------------------------------------------------------
/apps/www/app/register/components/register-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@umamin/ui/components/button";
4 | import Link from "next/link";
5 | import { toast } from "sonner";
6 | import type * as z from "zod";
7 | import { useAppForm } from "@/hooks/form";
8 | import { signup } from "@/lib/auth";
9 | import { registerSchema } from "@/lib/schema";
10 |
11 | export function RegisterForm() {
12 | const form = useAppForm({
13 | defaultValues: {
14 | username: "",
15 | password: "",
16 | confirmPassword: "",
17 | } as z.infer,
18 | validators: {
19 | onSubmit: registerSchema,
20 | },
21 | onSubmit: async ({ value }) => {
22 | const res = await signup(value);
23 | if (res?.error) {
24 | toast.error(res.error);
25 | }
26 | },
27 | });
28 |
29 | return (
30 | (
53 |
54 | )}
55 | />
56 |
57 | (
60 |
65 | )}
66 | />
67 |
68 |
69 |
70 |
71 |
72 |
73 |
78 |
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/apps/www/app/social/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@umamin/ui/lib/utils";
4 | import { AnimatedShinyText } from "@/components/animated-shiny-text";
5 | import { SocialCard } from "./components/social-card";
6 |
7 | const data = [
8 | {
9 | imageUrl:
10 | "https://lh3.googleusercontent.com/a/ACg8ocK4CtuGuDZlPy9H_DMb3EQIue9Hrd5bqYcMZOY-Xb8LcuyqsBI=s96-c",
11 | username: "umamin",
12 | displayName: "Umamin Official",
13 | createdAt: new Date(1718604131 * 1000), // Fri Jun 17 2024 ...
14 | content:
15 | "An open-source social platform built exclusively for the Umamin community.",
16 | isLiked: true,
17 | isVerified: true,
18 | likes: 24,
19 | comments: 9,
20 | },
21 | {
22 | imageUrl:
23 | "https://lh3.googleusercontent.com/a/ACg8ocJf40m8VVe3wNxhgBe11Bm7ukLSPeR0SDPPg6q8wq6NYRZtCYk=s96-c",
24 | username: "josh",
25 | displayName: "Josh Daniel",
26 | createdAt: new Date(1718342984 * 1000), // Tue Jun 14 2024 ...
27 | content:
28 | "We're building Umamin Social, a new platform to connect the community. Coming soon! 🚀",
29 | isLiked: false,
30 | isVerified: false,
31 | likes: 7,
32 | comments: 4,
33 | },
34 | ];
35 |
36 | export default function Social() {
37 | return (
38 |
39 |
40 |
45 |
46 | Coming Soon!
47 |
48 |
49 |
50 |
51 | Umamin Social
52 |
53 |
54 |
55 |
56 | {data.map((props) => (
57 |
58 | ))}
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/packages/ui/src/components/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { cn } from "@umamin/ui/lib/utils";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import type * as React from "react";
5 |
6 | const buttonVariants = cva(
7 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
15 | outline:
16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
17 | secondary:
18 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
19 | ghost:
20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27 | icon: "size-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | },
35 | );
36 |
37 | function Button({
38 | className,
39 | variant,
40 | size,
41 | asChild = false,
42 | ...props
43 | }: React.ComponentProps<"button"> &
44 | VariantProps & {
45 | asChild?: boolean;
46 | }) {
47 | const Comp = asChild ? Slot : "button";
48 |
49 | return (
50 |
55 | );
56 | }
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/packages/ui/src/components/card.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@umamin/ui/lib/utils";
2 | import type * as React from "react";
3 |
4 | function Card({ className, ...props }: React.ComponentProps<"div">) {
5 | return (
6 |
14 | );
15 | }
16 |
17 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
18 | return (
19 |
27 | );
28 | }
29 |
30 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
31 | return (
32 |
37 | );
38 | }
39 |
40 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
41 | return (
42 |
47 | );
48 | }
49 |
50 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
51 | return (
52 |
60 | );
61 | }
62 |
63 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
64 | return (
65 |
70 | );
71 | }
72 |
73 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
80 | );
81 | }
82 |
83 | export {
84 | Card,
85 | CardHeader,
86 | CardFooter,
87 | CardTitle,
88 | CardAction,
89 | CardDescription,
90 | CardContent,
91 | };
92 |
--------------------------------------------------------------------------------
/apps/www/app/settings/components/danger-settings.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Alert,
5 | AlertDescription,
6 | AlertTitle,
7 | } from "@umamin/ui/components/alert";
8 | import { Button } from "@umamin/ui/components/button";
9 | import {
10 | Dialog,
11 | DialogContent,
12 | DialogDescription,
13 | DialogHeader,
14 | DialogTitle,
15 | DialogTrigger,
16 | } from "@umamin/ui/components/dialog";
17 | import { Input } from "@umamin/ui/components/input";
18 | import { useState } from "react";
19 | import { deleteAccountAction } from "@/app/actions/user";
20 | import { DeleteButton } from "./danger-button";
21 |
22 | export function DangerSettings() {
23 | const [confirmText, setConfirmText] = useState("");
24 | return (
25 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/apps/www/components/demo.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Avatar,
5 | AvatarFallback,
6 | AvatarImage,
7 | } from "@umamin/ui/components/avatar";
8 | import { Card, CardHeader } from "@umamin/ui/components/card";
9 | import { BadgeCheckIcon, LockIcon, ScanFaceIcon } from "lucide-react";
10 | import { AnimatedShinyText } from "./animated-shiny-text";
11 | import { GridPattern } from "./grid-pattern";
12 |
13 | export function Demo() {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
To:
22 |
Umamin Official
23 |
24 |
25 |
26 | umamin
27 |
28 |
29 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
41 | Send me an anonymous message!
42 |
43 |
44 |
45 |
46 |
47 | SVHPJZ57QbFTnt3CqdpV+JPg6GCgPh/MbCXA/TsXRAWYEwQN2Xwtcl4=
48 |
49 |
50 |
51 |
52 | Advanced Encryption Standard
53 |
54 |
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/apps/www/app/settings/components/settings-tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQuery } from "@tanstack/react-query";
4 | import {
5 | Tabs,
6 | TabsContent,
7 | TabsList,
8 | TabsTrigger,
9 | } from "@umamin/ui/components/tabs";
10 | import { useMemo } from "react";
11 | import { getCurrentUserAction } from "@/app/actions/user";
12 | import { AccountSettings } from "./account-settings";
13 | import { GeneralSettings } from "./general-settings";
14 | import { PrivacySettings } from "./privacy-settings";
15 | import { SettingsSkeleton } from "./settings-skeleton";
16 |
17 | export function SettingsTabs() {
18 | const { data, isLoading } = useQuery({
19 | queryKey: ["current_user"],
20 | queryFn: getCurrentUserAction,
21 | });
22 |
23 | const userData = useMemo(() => {
24 | if (!data?.user) return null;
25 |
26 | const { accounts, ...rest } = data.user;
27 |
28 | return {
29 | ...rest,
30 | account: accounts?.length ? accounts[0] : null,
31 | };
32 | }, [data?.user]);
33 |
34 | return (
35 |
36 |
37 |
41 | General
42 |
43 |
47 | Account
48 |
49 |
53 | Privacy
54 |
55 |
56 |
57 | {isLoading || !userData ? (
58 |
59 | ) : (
60 | <>
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | >
73 | )}
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/apps/www/app/register/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import dynamic from "next/dynamic";
3 | import Link from "next/link";
4 | import { redirect } from "next/navigation";
5 | import { getSession } from "@/lib/auth";
6 | import { RegisterForm } from "./components/register-form";
7 |
8 | const BrowserWarning = dynamic(() => import("@/components/browser-warning"));
9 |
10 | export const metadata: Metadata = {
11 | title: "Umamin — Register",
12 | description:
13 | "Create an account on Umamin to start sending and receiving encrypted anonymous messages. Join our secure platform and ensure your privacy today.",
14 | keywords: [
15 | "Umamin register",
16 | "sign up for Umamin",
17 | "anonymous messaging sign up",
18 | ],
19 | robots: {
20 | index: true,
21 | follow: true,
22 | },
23 | openGraph: {
24 | type: "website",
25 | title: "Umamin — Register",
26 | description:
27 | "Create an account on Umamin to start sending and receiving encrypted anonymous messages. Join our secure platform and ensure your privacy today.",
28 | url: "https://www.umamin.link/register",
29 | },
30 | twitter: {
31 | card: "summary_large_image",
32 | title: "Umamin — Register",
33 | description:
34 | "Create an account on Umamin to start sending and receiving encrypted anonymous messages. Join our secure platform and ensure your privacy today.",
35 | },
36 | };
37 |
38 | export default async function Register() {
39 | const { session } = await getSession();
40 |
41 | if (session) {
42 | redirect("/inbox");
43 | }
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 | Umamin Account
52 |
53 |
54 | By creating an account, you agree to our{" "}
55 |
56 | Privacy Policy
57 | {" "}
58 | and{" "}
59 |
60 | Terms of Service
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | Already have an account?{" "}
69 |
70 | Login
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/apps/www/app/social/components/social-card.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Avatar,
3 | AvatarFallback,
4 | AvatarImage,
5 | } from "@umamin/ui/components/avatar";
6 | import { cn } from "@umamin/ui/lib/utils";
7 | import {
8 | BadgeCheckIcon,
9 | HeartIcon,
10 | MessageCircleIcon,
11 | ScanFaceIcon,
12 | } from "lucide-react";
13 | import Link from "next/link";
14 | import { shortTimeAgo } from "@/lib/utils";
15 |
16 | type Props = {
17 | imageUrl: string;
18 | username: string;
19 | displayName: string;
20 | createdAt: Date;
21 | content: string;
22 | isLiked: boolean;
23 | isVerified: boolean;
24 | likes: number;
25 | comments: number;
26 | };
27 |
28 | export function SocialCard(props: Props) {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 | {props.displayName}
46 |
47 |
48 | {props.isVerified && (
49 |
50 | )}
51 | @{props.username}
52 |
53 |
54 |
55 | {shortTimeAgo(props.createdAt)}
56 |
57 |
58 |
59 |
{props.content}
60 |
61 |
62 |
67 |
72 | {props.likes}
73 |
74 |
75 |
76 |
77 | {props.comments}
78 |
79 |
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/apps/www/app/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Alert,
3 | AlertDescription,
4 | AlertTitle,
5 | } from "@umamin/ui/components/alert";
6 | import { Link2OffIcon } from "lucide-react";
7 | import type { Metadata } from "next";
8 | import { redirect } from "next/navigation";
9 | import { getSession, logout } from "@/lib/auth";
10 | import { SettingsTabs } from "./components/settings-tabs";
11 | import { SignOutButton } from "./components/sign-out-button";
12 |
13 | export const metadata: Metadata = {
14 | title: "Umamin — Settings",
15 | description:
16 | "Manage your preferences and account settings on Umamin. Customize your profile, adjust privacy settings, and control how you interact anonymously.",
17 | robots: {
18 | index: false,
19 | follow: false,
20 | },
21 | openGraph: {
22 | type: "website",
23 | title: "Umamin — Settings",
24 | description:
25 | "Manage your preferences and account settings on Umamin. Customize your profile, adjust privacy settings, and control how you interact anonymously.",
26 | url: "https://www.umamin.link/settings",
27 | },
28 | twitter: {
29 | card: "summary_large_image",
30 | title: "Umamin — Settings",
31 | description:
32 | "Manage your preferences and account settings on Umamin. Customize your profile, adjust privacy settings, and control how you interact anonymously.",
33 | },
34 | };
35 |
36 | export default async function Settings({
37 | searchParams,
38 | }: {
39 | searchParams: Promise<{ error?: string }>;
40 | }) {
41 | const { session } = await getSession();
42 | const error = (await searchParams).error;
43 |
44 | if (!session) {
45 | redirect("/login");
46 | }
47 |
48 | return (
49 |
50 |
51 |
Settings
52 |
55 |
56 |
57 |
58 | Manage your account settings
59 |
60 |
61 | {error === "already_linked" && (
62 |
63 |
64 | Failed to link account
65 |
66 | Google account already connected to a different profile.
67 |
68 |
69 | )}
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/apps/www/app/user/[username]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@umamin/ui/components/button";
2 | import { MessageSquareMoreIcon, UserPlusIcon } from "lucide-react";
3 | import type { Metadata } from "next";
4 | import dynamic from "next/dynamic";
5 | import Link from "next/link";
6 | import { notFound } from "next/navigation";
7 | import { UserCard } from "@/components/user-card";
8 | import { formatUsername, getBaseUrl } from "@/lib/utils";
9 |
10 | const AdContainer = dynamic(() => import("@/components/ad-container"));
11 |
12 | export async function generateMetadata({
13 | params,
14 | }: {
15 | params: Promise<{ username: string }>;
16 | }): Promise {
17 | const param = await params;
18 | const username = formatUsername(param.username);
19 |
20 | const title = username
21 | ? `(@${username}) on Umamin`
22 | : "Umamin — User not found";
23 |
24 | const description = username
25 | ? `Profile of @${username} on Umamin. Join Umamin to connect with @${username} and engage in anonymous messaging.`
26 | : "This user does not exist on Umamin.";
27 |
28 | return {
29 | title,
30 | description,
31 | keywords: [
32 | `Umamin profile`,
33 | `@${username}`,
34 | `anonymous messaging`,
35 | `user activity`,
36 | `Umamin user`,
37 | ],
38 | openGraph: {
39 | type: "profile",
40 | title,
41 | description,
42 | url: `https://www.umamin.link/user/${username}`,
43 | },
44 | twitter: {
45 | card: "summary",
46 | title,
47 | description,
48 | },
49 | };
50 | }
51 |
52 | export default async function Page({
53 | params,
54 | }: {
55 | params: Promise<{ username: string }>;
56 | }) {
57 | const { username } = await params;
58 | const res = await fetch(`${getBaseUrl()}/api/users/${username}`);
59 |
60 | if (!res.ok) {
61 | notFound();
62 | }
63 |
64 | const user = await res.json();
65 |
66 | return (
67 |
68 |
69 |
70 |
71 |
75 |
76 |
82 |
83 |
84 | {/* v2-user */}
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/apps/www/app/notes/components/note-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMutation, useQueryClient } from "@tanstack/react-query";
4 | import { Button } from "@umamin/ui/components/button";
5 | import { Label } from "@umamin/ui/components/label";
6 | import { Switch } from "@umamin/ui/components/switch";
7 | import { Textarea } from "@umamin/ui/components/textarea";
8 | import { Loader2Icon, MessageSquareShareIcon } from "lucide-react";
9 | import { useState } from "react";
10 | import { toast } from "sonner";
11 | import { createNoteAction } from "@/app/actions/note";
12 |
13 | export function NoteForm() {
14 | const queryClient = useQueryClient();
15 | const [content, setContent] = useState("");
16 | const [isAnonymous, setIsAnonymous] = useState(false);
17 |
18 | const updateNoteMutation = useMutation({
19 | mutationFn: createNoteAction,
20 | onSuccess: (data) => {
21 | if (data?.error) {
22 | toast.error(data.error);
23 | return;
24 | }
25 |
26 | toast.success("Your note has been shared with the community.");
27 | queryClient.invalidateQueries({ queryKey: ["current_note"] });
28 | setContent("");
29 | },
30 | onError: (err) => {
31 | console.log(err);
32 | toast.error("An error occurred while sharing your note.");
33 | },
34 | });
35 |
36 | return (
37 |
38 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/apps/www/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "www",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "ui:add": "pnpm dlx shadcn@canary add",
8 | "build": "next build --turbopack",
9 | "start": "next start",
10 | "clean": "rm -rf .next .turbo node_modules"
11 | },
12 | "dependencies": {
13 | "@bprogress/next": "^3.2.12",
14 | "@libsql/client": "^0.15.15",
15 | "@mdx-js/loader": "^3.1.1",
16 | "@mdx-js/react": "^3.1.1",
17 | "@next/mdx": "16.0.10",
18 | "@next/third-parties": "16.0.10",
19 | "@node-rs/argon2": "^2.0.2",
20 | "@oslojs/crypto": "^1.0.1",
21 | "@oslojs/encoding": "^1.1.0",
22 | "@radix-ui/react-alert-dialog": "^1.1.15",
23 | "@radix-ui/react-avatar": "^1.1.11",
24 | "@radix-ui/react-collapsible": "^1.1.12",
25 | "@radix-ui/react-dialog": "^1.1.15",
26 | "@radix-ui/react-dropdown-menu": "^2.1.16",
27 | "@radix-ui/react-label": "^2.1.8",
28 | "@radix-ui/react-slot": "^1.2.4",
29 | "@radix-ui/react-switch": "^1.2.6",
30 | "@radix-ui/react-tabs": "^1.1.13",
31 | "@tanstack/react-form": "^1.27.4",
32 | "@tanstack/react-pacer": "^0.17.4",
33 | "@tanstack/react-query": "^5.90.12",
34 | "@tanstack/react-query-devtools": "^5.91.1",
35 | "@tanstack/react-virtual": "^3.13.12",
36 | "@types/mdx": "^2.0.13",
37 | "@umamin/db": "workspace:*",
38 | "@umamin/encryption": "workspace:*",
39 | "@umamin/ui": "workspace:*",
40 | "@upstash/redis": "^1.35.7",
41 | "arctic": "^3.7.0",
42 | "babel-plugin-react-compiler": "^1.0.0",
43 | "class-variance-authority": "^0.7.1",
44 | "clsx": "^2.1.1",
45 | "date-fns": "^4.1.0",
46 | "drizzle-orm": "^0.45.1",
47 | "lucide-react": "^0.561.0",
48 | "modern-screenshot": "^4.6.7",
49 | "nanoid": "^5.1.6",
50 | "next": "16.0.10",
51 | "next-themes": "^0.4.6",
52 | "react": "19.2.3",
53 | "react-dom": "19.2.3",
54 | "react-intersection-observer": "^10.0.0",
55 | "remark-gfm": "^4.0.1",
56 | "sonner": "^2.0.7",
57 | "tailwind-merge": "^3.4.0",
58 | "vaul": "^1.1.2",
59 | "zod": "^4.2.1"
60 | },
61 | "devDependencies": {
62 | "@tailwindcss/postcss": "^4.1.18",
63 | "@tailwindcss/typography": "^0.5.19",
64 | "@types/node": "^22.19.3",
65 | "@types/react": "19.2.7",
66 | "@types/react-dom": "19.2.3",
67 | "tailwindcss": "^4.1.18",
68 | "tw-animate-css": "^1.4.0",
69 | "typescript": "^5.9.3"
70 | },
71 | "pnpm": {
72 | "overrides": {
73 | "@types/react": "19.2.7",
74 | "@types/react-dom": "19.2.3"
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/apps/www/app/notes/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@umamin/ui/components/button";
2 | import { SquarePenIcon } from "lucide-react";
3 | import type { Metadata } from "next";
4 | import Link from "next/link";
5 | import { getSession } from "@/lib/auth";
6 | import { CurrentUserNote } from "./components/current-user-note";
7 | import { NoteForm } from "./components/note-form";
8 | import { NoteList } from "./components/note-list";
9 |
10 | export const metadata: Metadata = {
11 | title: "Umamin — Notes",
12 | description:
13 | "Explore notes on Umamin, the open-source platform for sending and receiving encrypted anonymous messages. Send your messages anonymously and discover what others have to share.",
14 | keywords: [
15 | "Umamin notes",
16 | "anonymous notes",
17 | "send messages",
18 | "view messages",
19 | ],
20 | robots: {
21 | index: true,
22 | follow: true,
23 | },
24 | openGraph: {
25 | type: "website",
26 | title: "Umamin — Notes",
27 | description:
28 | "Explore notes on Umamin, the open-source platform for sending and receiving encrypted anonymous messages. Send your messages anonymously and discover what others have to share.",
29 | url: "https://www.umamin.link/notes",
30 | },
31 | twitter: {
32 | card: "summary_large_image",
33 | title: "Umamin — Notes",
34 | description:
35 | "Explore notes on Umamin, the open-source platform for sending and receiving encrypted anonymous messages. Send your messages anonymously and discover what others have to share.",
36 | },
37 | };
38 |
39 | export default async function Page() {
40 | const { user } = await getSession();
41 |
42 | return (
43 |
44 |
45 | Umamin Notes
46 |
47 |
48 | {user ? (
49 | <>
50 |
51 |
52 | >
53 | ) : (
54 |
55 |
56 |
57 |
Umamin Notes
58 |
59 | Login to start writing notes
60 |
61 |
62 |
63 |
66 |
67 | )}
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/packages/db/src/schema/user.ts:
--------------------------------------------------------------------------------
1 | import { relations, sql } from "drizzle-orm";
2 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3 | import { nanoid } from "nanoid";
4 | import { messageTable } from "./message";
5 |
6 | export const userTable = sqliteTable("user", {
7 | id: text("id")
8 | .primaryKey()
9 | .$defaultFn(() => nanoid()),
10 | displayName: text("display_name"),
11 | username: text("username").notNull().unique(),
12 | passwordHash: text("password_hash"),
13 | bio: text("bio"),
14 | imageUrl: text("image_url"),
15 | quietMode: integer("quiet_mode", { mode: "boolean" })
16 | .notNull()
17 | .default(false),
18 | question: text("question").notNull().default("Send me an anonymous message!"),
19 | createdAt: integer("created_at", { mode: "timestamp" })
20 | .notNull()
21 | .default(sql`(unixepoch())`),
22 | updatedAt: integer("updated_at", { mode: "timestamp" }).$onUpdate(
23 | () => new Date(),
24 | ),
25 | });
26 |
27 | export const sessionTable = sqliteTable("session", {
28 | id: text("id").notNull().primaryKey(),
29 | userId: text("user_id")
30 | .notNull()
31 | .references(() => userTable.id, { onDelete: "cascade" }),
32 | expiresAt: integer("expires_at").notNull(),
33 | });
34 |
35 | export const sessionRelations = relations(sessionTable, ({ one }) => ({
36 | user: one(userTable, {
37 | fields: [sessionTable.userId],
38 | references: [userTable.id],
39 | }),
40 | }));
41 |
42 | export const accountTable = sqliteTable("oauth_account", {
43 | providerUserId: text("provider_user_id").primaryKey(),
44 | email: text("email").notNull(),
45 | picture: text("picture").notNull(),
46 | userId: text("user_id").notNull(),
47 | providerId: text("provider_id").notNull(),
48 | createdAt: integer("created_at", { mode: "timestamp" })
49 | .notNull()
50 | .default(sql`(unixepoch())`),
51 | updatedAt: integer("updated_at", { mode: "timestamp" }).$onUpdate(
52 | () => new Date(),
53 | ),
54 | });
55 |
56 | export const accountRelations = relations(accountTable, ({ one }) => ({
57 | user: one(userTable, {
58 | fields: [accountTable.userId],
59 | references: [userTable.id],
60 | }),
61 | }));
62 |
63 | export const userRelations = relations(userTable, ({ many }) => ({
64 | sentMessages: many(messageTable, { relationName: "sender" }),
65 | receivedMessages: many(messageTable, { relationName: "receiver" }),
66 | accounts: many(accountTable),
67 | }));
68 |
69 | export type SelectUser = typeof userTable.$inferSelect;
70 | export type SelectSession = typeof sessionTable.$inferSelect;
71 | export type SelectAccount = typeof accountTable.$inferSelect;
72 |
73 | export type InsertUser = typeof userTable.$inferInsert;
74 | export type InsertSession = typeof sessionTable.$inferInsert;
75 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/components/sent/sent-card.tsx:
--------------------------------------------------------------------------------
1 | import type { SelectMessage } from "@umamin/db/schema/message";
2 | import {
3 | Card,
4 | CardContent,
5 | CardFooter,
6 | CardHeader,
7 | } from "@umamin/ui/components/card";
8 | import { formatDistanceToNow } from "date-fns";
9 | import { CircleUserIcon } from "lucide-react";
10 | import { ChatList } from "@/components/chat-list";
11 | import { HoverPrefetchLink } from "@/components/hover-prefetch-link";
12 | import type { PublicUser } from "@/types/user";
13 |
14 | export function SentMessageCard({
15 | data,
16 | }: {
17 | data: SelectMessage & { receiver: PublicUser };
18 | }) {
19 | // const menuItems = [
20 | // {
21 | // title: "View",
22 | // onClick: () => {
23 | // toast.error("Not implemented yet");
24 | // },
25 | // },
26 | // {
27 | // title: "Download",
28 | // onClick: () => {
29 | // toast.error("Not implemented yet");
30 | // },
31 | // },
32 | // {
33 | // title: "Delete",
34 | // onClick: () => {
35 | // toast.error("Not implemented yet");
36 | // },
37 | // className: "text-red-500",
38 | // },
39 | // ];
40 | //
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 |
52 | {data.receiver?.username}
53 |
54 |
55 |
56 |
umamin
57 | {/*
*/}
58 |
59 |
60 |
61 |
67 |
68 |
69 |
70 |
71 | {formatDistanceToNow(data.createdAt, {
72 | addSuffix: true,
73 | })}
74 |
75 |
76 | {/*
77 |
78 | @johndoe
79 |
80 |
81 | */}
82 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/apps/www/app/api/notes/route.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@umamin/db";
2 | import { noteTable } from "@umamin/db/schema/note";
3 | import { userTable } from "@umamin/db/schema/user";
4 | import { and, desc, eq, lt, or } from "drizzle-orm";
5 | import { cacheLife } from "next/cache";
6 | import type { NextRequest } from "next/server";
7 |
8 | export async function GET(req: NextRequest) {
9 | try {
10 | const searchParams = req.nextUrl.searchParams;
11 | const cursor = searchParams.get("cursor");
12 |
13 | const result = await (async () => {
14 | "use cache";
15 | cacheLife({ revalidate: 30 });
16 |
17 | // biome-ignore lint/suspicious/noImplicitAnyLet: temp
18 | let cursorCondition;
19 |
20 | if (cursor) {
21 | const sep = cursor.indexOf(".");
22 | if (sep > 0) {
23 | const ms = Number(cursor.slice(0, sep));
24 | const cursorId = cursor.slice(sep + 1);
25 | const cursorDate = new Date(ms);
26 | cursorCondition = or(
27 | lt(noteTable.updatedAt, cursorDate),
28 | and(
29 | eq(noteTable.updatedAt, cursorDate),
30 | lt(noteTable.id, cursorId),
31 | ),
32 | );
33 | }
34 | }
35 |
36 | const baseQuery = db
37 | .select({
38 | note: noteTable,
39 | user: {
40 | id: userTable.id,
41 | username: userTable.username,
42 | displayName: userTable.displayName,
43 | imageUrl: userTable.imageUrl,
44 | quietMode: userTable.quietMode,
45 | createdAt: userTable.createdAt,
46 | },
47 | })
48 | .from(noteTable)
49 | .leftJoin(userTable, eq(noteTable.userId, userTable.id))
50 | .orderBy(desc(noteTable.updatedAt), desc(noteTable.id))
51 | .limit(40);
52 |
53 | const rows = await (cursorCondition
54 | ? baseQuery.where(cursorCondition)
55 | : baseQuery);
56 |
57 | const notes = rows.map(({ note, user }) => ({
58 | ...note,
59 | user: user ?? undefined,
60 | }));
61 |
62 | const notesData = notes.map(({ user, userId, ...note }) =>
63 | note.isAnonymous ? { ...note } : { user, userId, ...note },
64 | );
65 |
66 | return {
67 | data: notesData,
68 | nextCursor:
69 | notesData.length === 40
70 | ? `${notesData[notesData.length - 1].updatedAt?.getTime()}.${
71 | notesData[notesData.length - 1].id
72 | }`
73 | : null,
74 | };
75 | })();
76 |
77 | return Response.json(result);
78 | } catch (error) {
79 | console.error("Error fetching notes:", error);
80 | return Response.json({ error: "Internal server error" }, { status: 500 });
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/packages/encryption/src/index.ts:
--------------------------------------------------------------------------------
1 | const { crypto } = globalThis;
2 | const { subtle } = crypto;
3 |
4 | function toUint8Array(base64: string): Uint8Array {
5 | const buf = Buffer.from(base64, "base64");
6 | return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
7 | }
8 |
9 | function toBase64(u8: Uint8Array): string {
10 | return Buffer.from(u8).toString("base64");
11 | }
12 |
13 | function getAesKeyFromEnv(): string {
14 | const key = process.env.AES_256_GCM_KEY;
15 | if (!key) throw new Error("AES_256_GCM_KEY environment variable not set");
16 | return key;
17 | }
18 |
19 | async function importAesKey(base64Key: string): Promise {
20 | const rawGeneric = toUint8Array(base64Key);
21 | const rawClean = rawGeneric.buffer.slice(
22 | rawGeneric.byteOffset,
23 | rawGeneric.byteOffset + rawGeneric.byteLength,
24 | ) as ArrayBuffer;
25 |
26 | return await subtle.importKey(
27 | "raw",
28 | rawClean,
29 | { name: "AES-GCM" },
30 | /* extractable */ true,
31 | ["encrypt", "decrypt"],
32 | );
33 | }
34 |
35 | function splitPayload(payload: string): {
36 | cipherText: Uint8Array;
37 | iv: Uint8Array;
38 | } {
39 | const [ctB64, ivB64] = payload.split(".");
40 | if (!ctB64 || !ivB64) throw new Error("Invalid payload format");
41 | return {
42 | cipherText: toUint8Array(ctB64),
43 | iv: toUint8Array(ivB64),
44 | };
45 | }
46 |
47 | export async function aesEncrypt(plainText: string): Promise {
48 | try {
49 | const rawBase64 = getAesKeyFromEnv();
50 | const key = await importAesKey(rawBase64);
51 |
52 | const enc = new TextEncoder();
53 |
54 | const ivGen = crypto.getRandomValues(new Uint8Array(12));
55 | const iv = new Uint8Array(ivGen);
56 |
57 | const plainU8 = new Uint8Array(enc.encode(plainText));
58 |
59 | const cipherBuffer = await subtle.encrypt(
60 | { name: "AES-GCM", iv },
61 | key,
62 | plainU8,
63 | );
64 |
65 | const cipherU8 = new Uint8Array(cipherBuffer);
66 |
67 | return `${toBase64(cipherU8)}.${toBase64(iv)}`;
68 | } catch (err) {
69 | console.error("AES encryption error:", err);
70 | throw err;
71 | }
72 | }
73 |
74 | export async function aesDecrypt(payload: string): Promise {
75 | try {
76 | const rawBase64 = getAesKeyFromEnv();
77 | const key = await importAesKey(rawBase64);
78 |
79 | const { cipherText: ctGen, iv: ivGen } = splitPayload(payload);
80 |
81 | const cipherText = new Uint8Array(ctGen);
82 | const iv = new Uint8Array(ivGen);
83 |
84 | const plainBuffer = await subtle.decrypt(
85 | { name: "AES-GCM", iv },
86 | key,
87 | cipherText,
88 | );
89 |
90 | return new TextDecoder().decode(plainBuffer);
91 | } catch (err) {
92 | console.error("AES decryption error:", err);
93 | throw err;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/apps/www/markdown/privacy.mdx:
--------------------------------------------------------------------------------
1 | # Privacy Policy
2 |
3 | **Effective Date:** July 1, 2024
4 |
5 | ## 1. Introduction
6 |
7 | Welcome to Umamin! We are committed to protecting your privacy and ensuring that your information is handled in a safe and responsible manner. This Privacy Policy outlines how we collect, use, and protect your information when you use our open-source platform for sending and receiving encrypted anonymous messages.
8 |
9 | ## 2. Information We Collect
10 |
11 | ### 2.1 Personal Information
12 |
13 | - **Authentication:** When you sign up or log in to Umamin, we collect your username and password. Passwords are hashed and securely stored.
14 | - **Google OAuth:** If you choose to authenticate using Google OAuth, we collect basic profile information from your Google account, such as your email address and profile picture. We use this to avoid account loss, display your profile picture, and show your connected account's email in settings.
15 |
16 | ### 2.2 Usage Data
17 |
18 | - **Google Analytics:** We use Google Analytics to collect information about how you use our platform. This includes:
19 | - Number of users
20 | - Session statistics
21 | - Browser and device information
22 |
23 | ## 3. How We Use Your Information
24 |
25 | - **Authentication and Security:** To verify your identity and protect your account.
26 | - **Service Improvement:** To analyze usage patterns and improve our platform’s functionality and user experience.
27 |
28 | ## 4. How We Protect Your Information
29 |
30 | - We implement a variety of security measures to maintain the safety of your personal information. These measures include encryption, secure servers, and hashed passwords.
31 |
32 | ## 5. Sharing Your Information
33 |
34 | - We do not sell, trade, or otherwise transfer your personal information to outside parties. This does not include trusted third parties who assist us in operating our platform, conducting our business, or serving our users, so long as those parties agree to keep this information confidential.
35 |
36 | ## 6. Your Choices
37 |
38 | - You can choose not to provide certain information, although it may affect your ability to use some features of our platform.
39 | - You can disable Google Analytics tracking through your browser settings or by using opt-out tools provided by Google.
40 | - You can delete your account and all associated data in the settings.
41 |
42 | ## 7. Third-Party Links
43 |
44 | - Our platform may contain links to other websites. We are not responsible for the privacy practices of these other sites. We encourage you to read the privacy policies of any third-party sites you visit.
45 |
46 | ## 8. Changes to Our Privacy Policy
47 |
48 | - We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on our platform. You are advised to review this Privacy Policy periodically for any changes.
49 |
50 | ## 9. Contact Us
51 |
52 | If you have any questions about this Privacy Policy, please contact us at umamin@omsimos.com.
53 |
--------------------------------------------------------------------------------
/apps/www/app/actions/note.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@umamin/db";
4 | import { noteTable } from "@umamin/db/schema/note";
5 | import { eq, sql } from "drizzle-orm";
6 | import { cacheTag, updateTag } from "next/cache";
7 | import * as z from "zod";
8 | import { getSession } from "@/lib/auth";
9 | import { formatContent } from "@/lib/utils";
10 |
11 | const createNoteSchema = z.object({
12 | isAnonymous: z.boolean().default(false),
13 | content: z
14 | .string()
15 | .min(1, { error: "Content cannot be empty" })
16 | .max(500, { error: "Content cannot exceed 500 characters" }),
17 | });
18 |
19 | export async function createNoteAction(
20 | params: z.infer,
21 | ) {
22 | const result = createNoteSchema.safeParse(params);
23 |
24 | if (!result.success) {
25 | return { error: result.error.issues[0].message };
26 | }
27 |
28 | const { isAnonymous, content } = result.data;
29 |
30 | try {
31 | const { session } = await getSession();
32 |
33 | if (!session?.userId) {
34 | return { error: "User not authenticated" };
35 | }
36 |
37 | const formattedContent = formatContent(content);
38 |
39 | await db
40 | .insert(noteTable)
41 | .values({
42 | userId: session?.userId,
43 | content: formattedContent,
44 | isAnonymous,
45 | })
46 | .onConflictDoUpdate({
47 | target: noteTable.userId,
48 | set: {
49 | content: formattedContent,
50 | isAnonymous,
51 | updatedAt: sql`(unixepoch())`,
52 | },
53 | });
54 |
55 | updateTag(`current-note:${session.userId}`);
56 | } catch (error) {
57 | console.log("Error creating note:", error);
58 | return { error: "Failed to create note" };
59 | }
60 | }
61 |
62 | export const getCurrentNoteAction = async () => {
63 | const { session } = await getSession();
64 |
65 | if (!session?.userId) {
66 | throw new Error("User not authenticated");
67 | }
68 |
69 | const getCachedData = async () => {
70 | "use cache";
71 | cacheTag(`current-note:${session.userId}`);
72 |
73 | const [data] = await db
74 | .select()
75 | .from(noteTable)
76 | .where(eq(noteTable.userId, session.userId))
77 | .limit(1);
78 |
79 | return data;
80 | };
81 | const result = await getCachedData();
82 | return result;
83 | };
84 |
85 | export const clearNoteAction = async () => {
86 | try {
87 | const { session } = await getSession();
88 |
89 | if (!session?.userId) {
90 | return { error: "User not authenticated" };
91 | }
92 |
93 | await db
94 | .update(noteTable)
95 | .set({ content: "" })
96 | .where(eq(noteTable.userId, session.userId));
97 |
98 | updateTag(`current-note:${session.userId}`);
99 | } catch (error) {
100 | console.log("Error clearing note:", error);
101 | return { error: "Failed to clear note" };
102 | }
103 | };
104 |
--------------------------------------------------------------------------------
/apps/www/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from "@umamin/ui/components/badge";
2 | import {
3 | LayoutDashboardIcon,
4 | LinkIcon,
5 | LogInIcon,
6 | MessagesSquareIcon,
7 | ScrollTextIcon,
8 | UserCogIcon,
9 | } from "lucide-react";
10 | import Link from "next/link";
11 | import { getSession } from "@/lib/auth";
12 | import { ShareLinkDialog } from "./share-link-dialog";
13 | import { ThemeToggle } from "./theme-toggle";
14 |
15 | export async function Navbar() {
16 | const { user, session } = await getSession();
17 | const version = process.env.NEXT_PUBLIC_VERSION
18 | ? process.env.NEXT_PUBLIC_VERSION
19 | : "v3.0.0";
20 |
21 | return (
22 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/apps/www/components/user-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { SelectUser } from "@umamin/db/schema/user";
4 | import {
5 | Avatar,
6 | AvatarFallback,
7 | AvatarImage,
8 | } from "@umamin/ui/components/avatar";
9 | import { Badge } from "@umamin/ui/components/badge";
10 | import { cn } from "@umamin/ui/lib/utils";
11 | import { formatDistanceToNow } from "date-fns";
12 | import {
13 | BadgeCheckIcon,
14 | CalendarDaysIcon,
15 | MessageSquareXIcon,
16 | MoonIcon,
17 | } from "lucide-react";
18 | import dynamic from "next/dynamic";
19 | import { isOlderThanOneYear } from "@/lib/utils";
20 | import { ShareButton } from "./share-button";
21 |
22 | const CopyLink = dynamic(() => import("./copy-link"), { ssr: false });
23 |
24 | export function UserCard({ user }: { user: SelectUser }) {
25 | return (
26 |
27 |
28 |
33 |
38 |
39 | {user?.username?.slice(0, 2).toUpperCase()}
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | {user.displayName ? user.displayName : user.username}
48 |
49 | {process.env.NEXT_PUBLIC_VERIFIED_USERS?.split(",").includes(
50 | user.username,
51 | ) &&
}
52 |
53 |
54 |
55 |
56 |
@{user.username}
57 |
58 |
59 |
60 |
61 |
66 | {user?.bio}
67 |
68 |
69 |
70 | {user.quietMode && (
71 |
72 |
73 |
74 | In quiet mode
75 |
76 |
77 | )}
78 |
79 |
80 |
81 |
82 |
83 | Joined{" "}
84 | {formatDistanceToNow(user.createdAt, {
85 | addSuffix: true,
86 | })}
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/apps/www/hooks/form.tsx:
--------------------------------------------------------------------------------
1 | import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
2 | import { Button } from "@umamin/ui/components/button";
3 | import { Input } from "@umamin/ui/components/input";
4 | import { Label } from "@umamin/ui/components/label";
5 | import { Textarea } from "@umamin/ui/components/textarea";
6 | import type { LucideIcon } from "lucide-react";
7 | import { LoadingIcon } from "@/components/loading-icon";
8 |
9 | const { fieldContext, formContext, useFieldContext, useFormContext } =
10 | createFormHookContexts();
11 |
12 | function TextField({
13 | label,
14 | isRequired,
15 | ...props
16 | }: { label: string; isRequired?: boolean } & React.ComponentProps<"input">) {
17 | const field = useFieldContext();
18 |
19 | return (
20 |
21 |
25 |
30 | field.handleChange(e.target.value.replace(/\s+/g, " "))
31 | }
32 | />
33 |
34 | {field.state.meta.errors.length > 0 && (
35 |
36 | {field.state.meta.errors[0].message}
37 |
38 | )}
39 |
40 | );
41 | }
42 |
43 | function TextareaField({
44 | label,
45 | isRequired,
46 | ...props
47 | }: { label: string; isRequired?: boolean } & React.ComponentProps<"textarea">) {
48 | const field = useFieldContext();
49 |
50 | return (
51 |
52 |
56 |
69 | );
70 | }
71 |
72 | function SubmitButton({
73 | label,
74 | icon,
75 | disabled,
76 | ...props
77 | }: { label: string; icon?: LucideIcon } & React.ComponentProps<"button">) {
78 | const form = useFormContext();
79 |
80 | return (
81 | [state.canSubmit, state.isSubmitting]}>
82 | {([canSubmit, isSubmitting]) => (
83 |
87 | )}
88 |
89 | );
90 | }
91 |
92 | export const { useAppForm } = createFormHook({
93 | fieldComponents: {
94 | TextField,
95 | TextareaField,
96 | },
97 | formComponents: {
98 | SubmitButton,
99 | },
100 | fieldContext,
101 | formContext,
102 | });
103 |
--------------------------------------------------------------------------------
/apps/www/app/to/[username]/page.tsx:
--------------------------------------------------------------------------------
1 | import { BadgeCheckIcon, LockIcon } from "lucide-react";
2 | import type { Metadata } from "next";
3 | import { notFound } from "next/navigation";
4 | import { ShareButton } from "@/components/share-button";
5 | import UnauthenticatedDialog from "@/components/unauthenticated-dialog";
6 | import { getSession } from "@/lib/auth";
7 | import { formatUsername, getBaseUrl } from "@/lib/utils";
8 | import { ChatForm } from "./components/chat-form";
9 |
10 | export async function generateMetadata({
11 | params,
12 | }: {
13 | params: Promise<{ username: string }>;
14 | }): Promise {
15 | const param = await params;
16 | const username = formatUsername(param.username);
17 |
18 | const title = `Send Encrypted Anonymous Message to @${username} | Umamin`;
19 |
20 | const description = `Send an encrypted anonymous message to @${username} on Umamin. Protect your identity while communicating securely and privately.`;
21 |
22 | return {
23 | title,
24 | description,
25 | keywords: [
26 | `anonymous message`,
27 | `encrypted messaging`,
28 | `send message to ${username}`,
29 | `secure communication`,
30 | `Umamin`,
31 | ],
32 | openGraph: {
33 | type: "website",
34 | title,
35 | description,
36 | url: `https://www.umamin.link/to/${username}`,
37 | },
38 | twitter: {
39 | card: "summary_large_image",
40 | title,
41 | description,
42 | },
43 | };
44 | }
45 |
46 | export default async function SendMessage({
47 | params,
48 | }: {
49 | params: Promise<{ username: string }>;
50 | }) {
51 | const { username } = await params;
52 | const res = await fetch(`${getBaseUrl()}/api/users/${username}`);
53 |
54 | if (!res.ok) {
55 | notFound();
56 | }
57 |
58 | const user = await res.json();
59 | const { session } = await getSession();
60 |
61 | return (
62 |
63 |
64 |
65 |
66 |
To:
67 |
68 | {user?.displayName ? user?.displayName : user?.username}
69 |
70 | {process.env.NEXT_PUBLIC_VERIFIED_USERS?.split(",").includes(
71 | user.username,
72 | ) &&
}
73 |
74 |
75 |
76 |
77 |
umamin
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | Advanced Encryption Standard
86 |
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/apps/www/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { formatDistanceToNow } from "date-fns";
2 | import { domToPng } from "modern-screenshot";
3 | import { customAlphabet, nanoid } from "nanoid";
4 | import { toast } from "sonner";
5 |
6 | export function generateUsernameId(length = 12) {
7 | const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
8 | const nanoid = customAlphabet(alphabet, length);
9 | const id = nanoid();
10 |
11 | return id;
12 | }
13 |
14 | export function shortTimeAgo(date: Date) {
15 | const distance = formatDistanceToNow(date);
16 |
17 | if (distance === "less than a minute") {
18 | return "just now";
19 | }
20 |
21 | const minutesMatch = distance.match(/(\d+)\s+min/);
22 | if (minutesMatch) {
23 | return `${minutesMatch[1]}m`;
24 | }
25 |
26 | const hoursMatch = distance.match(/(\d+)\s+hour/);
27 | if (hoursMatch) {
28 | return `${hoursMatch[1]}h`;
29 | }
30 |
31 | const daysMatch = distance.match(/(\d+)\s+day/);
32 | if (daysMatch) {
33 | return `${daysMatch[1]}d`;
34 | }
35 |
36 | const monthsMatch = distance.match(/(\d+)\s+month/);
37 | if (monthsMatch) {
38 | return `${monthsMatch[1]}mo`;
39 | }
40 |
41 | const yearsMatch = distance.match(/(\d+)\s+year/);
42 | if (yearsMatch) {
43 | return `${yearsMatch[1]}y`;
44 | }
45 |
46 | return distance;
47 | }
48 |
49 | export const getBaseUrl = () => {
50 | if (process.env.VERCEL_URL) {
51 | return `https://${process.env.VERCEL_URL}`;
52 | }
53 |
54 | return "http://localhost:3000";
55 | };
56 |
57 | export function formatUsername(username: string) {
58 | const formattedUsername = username.startsWith("%40")
59 | ? username.split("%40").at(1)
60 | : username;
61 |
62 | return formattedUsername ?? "";
63 | }
64 |
65 | export function formatContent(content: string) {
66 | return content.replace(/(\r\n|\n|\r){2,}/g, "\n\n");
67 | }
68 |
69 | export const saveImage = (id: string) => {
70 | const target = document.querySelector(`#${id}`);
71 |
72 | if (!target) {
73 | toast.error("An error occured");
74 | return;
75 | }
76 |
77 | toast.promise(
78 | domToPng(target, {
79 | quality: 1,
80 | scale: 4,
81 | backgroundColor: "#111113",
82 | style: {
83 | scale: "0.9",
84 | display: "grid",
85 | placeItems: "center",
86 | },
87 | })
88 | .then((dataUrl) => {
89 | const link = document.createElement("a");
90 | link.download = `umamin-${nanoid(5)}.png`;
91 | link.href = dataUrl;
92 | link.click();
93 | })
94 | .catch((err) => {
95 | console.log(err);
96 | }),
97 | {
98 | loading: "Saving...",
99 | success: "Download ready",
100 | error: "An error occured!",
101 | },
102 | );
103 | };
104 |
105 | export function isOlderThanOneYear(createdAt?: Date | string | null) {
106 | if (!createdAt) return false;
107 |
108 | const createdDate = new Date(createdAt);
109 | if (Number.isNaN(createdDate.getTime())) return false; // invalid date
110 |
111 | const oneYearAgo = new Date();
112 | oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
113 |
114 | return createdDate <= oneYearAgo;
115 | }
116 |
--------------------------------------------------------------------------------
/apps/www/lib/session.ts:
--------------------------------------------------------------------------------
1 | import { sha256 } from "@oslojs/crypto/sha2";
2 | import {
3 | encodeBase32LowerCaseNoPadding,
4 | encodeHexLowerCase,
5 | } from "@oslojs/encoding";
6 | import { db } from "@umamin/db";
7 | import {
8 | type InsertSession,
9 | type SelectSession,
10 | type SelectUser,
11 | sessionTable,
12 | userTable,
13 | } from "@umamin/db/schema/user";
14 |
15 | import { eq } from "drizzle-orm";
16 | import { cookies } from "next/headers";
17 |
18 | export function generateSessionToken(): string {
19 | const bytes = new Uint8Array(20);
20 | crypto.getRandomValues(bytes);
21 | const token = encodeBase32LowerCaseNoPadding(bytes);
22 | return token;
23 | }
24 |
25 | export async function createSession(
26 | token: string,
27 | userId: string,
28 | ): Promise {
29 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
30 | const session: InsertSession = {
31 | id: sessionId,
32 | userId,
33 | expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 30,
34 | };
35 |
36 | await db.insert(sessionTable).values(session);
37 | return session;
38 | }
39 |
40 | export async function validateSessionToken(
41 | token: string,
42 | ): Promise {
43 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
44 | const [result] = await db
45 | .select({
46 | session: sessionTable,
47 | user: userTable,
48 | })
49 | .from(sessionTable)
50 | .leftJoin(userTable, eq(sessionTable.userId, userTable.id))
51 | .where(eq(sessionTable.id, sessionId))
52 | .limit(1);
53 | // .$withCache(false);
54 |
55 | if (!result || !result.user) {
56 | return { session: null, user: null };
57 | }
58 |
59 | const { session, user } = result;
60 | if (Date.now() >= session.expiresAt) {
61 | await db.delete(sessionTable).where(eq(sessionTable.id, sessionId));
62 | return { session: null, user: null };
63 | }
64 | if (Date.now() >= session.expiresAt - 1000 * 60 * 60 * 24 * 15) {
65 | session.expiresAt = Date.now() + 1000 * 60 * 60 * 24 * 30;
66 |
67 | await db
68 | .update(sessionTable)
69 | .set({
70 | expiresAt: session.expiresAt,
71 | })
72 | .where(eq(sessionTable.id, sessionId));
73 | }
74 |
75 | return { session, user };
76 | }
77 |
78 | export async function setSessionTokenCookie(
79 | token: string,
80 | expiresAt: Date,
81 | ): Promise {
82 | const cookieStore = await cookies();
83 | cookieStore.set("session", token, {
84 | httpOnly: true,
85 | sameSite: "lax",
86 | secure: process.env.NODE_ENV === "production",
87 | expires: expiresAt,
88 | path: "/",
89 | });
90 | }
91 |
92 | export async function deleteSessionTokenCookie(): Promise {
93 | const cookieStore = await cookies();
94 | cookieStore.set("session", "", {
95 | httpOnly: true,
96 | sameSite: "lax",
97 | secure: process.env.NODE_ENV === "production",
98 | maxAge: 0,
99 | path: "/",
100 | });
101 | }
102 |
103 | export async function invalidateSession(sessionId: string) {
104 | await db.delete(sessionTable).where(eq(sessionTable.id, sessionId));
105 | }
106 |
107 | export type SessionValidationResult =
108 | | { session: SelectSession; user: SelectUser }
109 | | { session: null; user: null };
110 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/components/received/received-card-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMutation, useQueryClient } from "@tanstack/react-query";
4 | import {
5 | AlertDialog,
6 | AlertDialogAction,
7 | AlertDialogCancel,
8 | AlertDialogContent,
9 | AlertDialogDescription,
10 | AlertDialogFooter,
11 | AlertDialogHeader,
12 | AlertDialogTitle,
13 | } from "@umamin/ui/components/alert-dialog";
14 | import { Button } from "@umamin/ui/components/button";
15 | import { useState } from "react";
16 | import { toast } from "sonner";
17 | import { deleteMessageAction } from "@/app/actions/message";
18 | import { Menu } from "@/components/menu";
19 | import { saveImage } from "@/lib/utils";
20 | import { ReplyDialog } from "./reply-dialog";
21 |
22 | export type ReceivedMenuProps = {
23 | id: string;
24 | question: string;
25 | content: string;
26 | reply?: string | null;
27 | updatedAt?: Date | null;
28 | };
29 |
30 | export function ReceivedMessageMenu(props: ReceivedMenuProps) {
31 | const id = props.id;
32 | const queryClient = useQueryClient();
33 | const [replyDialogOpen, setReplyDialogOpen] = useState(false);
34 | const [open, setOpen] = useState(false);
35 |
36 | const deleteMutation = useMutation({
37 | mutationFn: async () => {
38 | const res = await deleteMessageAction(id);
39 |
40 | if (res.error) {
41 | throw new Error(res.error);
42 | }
43 | },
44 | onSuccess: () => {
45 | queryClient.invalidateQueries({ queryKey: ["received_messages"] });
46 | toast.success("Message deleted");
47 | },
48 | onError: (err) => {
49 | console.error(err);
50 | toast.error("Failed to delete message. Please try again.");
51 | },
52 | });
53 |
54 | const menuItems = [
55 | {
56 | title: "Reply",
57 | onClick: () => setReplyDialogOpen(true),
58 | },
59 | {
60 | title: "Save Image",
61 | onClick: () => saveImage(`umamin-${id}`),
62 | },
63 | {
64 | title: "Delete",
65 | onClick: () => setOpen(true),
66 | className: "text-red-500",
67 | },
68 | ];
69 |
70 | return (
71 | <>
72 |
73 |
74 |
75 | Are you absolutely sure?
76 |
77 | This action cannot be undone. This will permanently delete the
78 | message you received.
79 |
80 |
81 |
82 | Cancel
83 |
84 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
103 | >
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/apps/www/markdown/terms.mdx:
--------------------------------------------------------------------------------
1 | # Terms of Service
2 |
3 | **Effective Date:** July 1, 2024
4 |
5 | ## 1. Introduction
6 |
7 | Welcome to Umamin, an open-source social platform where users can send and receive encrypted anonymous messages. By using our platform, you agree to comply with and be bound by these Terms of Service. If you do not agree to these terms, please do not use our platform.
8 |
9 | ## 2. Account Registration
10 |
11 | - **Eligibility:** You must be at least 13 years old to use Umamin. By registering, you represent and warrant that you are eligible.
12 | - **Account Information:** When creating an account, you are responsible for maintaining the confidentiality of your account and password and for restricting access to your account.
13 |
14 | ## 3. User Content
15 |
16 | - **Notes and Messages:** You can add notes that others can see and send anonymous notes where your username and information are hidden. All messages sent and received are encrypted.
17 | - **Profile Information:** Each user has a profile with a display name, username, profile picture, and bio. Others can send you anonymous messages.
18 |
19 | ## 4. Acceptable Use
20 |
21 | - **Prohibited Activities:** You agree not to use Umamin for any unlawful purpose or in any way that could harm the platform or its users. Prohibited activities include, but are not limited to:
22 | - Posting or sending content that is illegal, harmful, or offensive.
23 | - Harassing or bullying other users.
24 | - Attempting to hack or gain unauthorized access to any part of the platform.
25 |
26 | ## 5. Privacy
27 |
28 | - **Data Collection:** We collect and use your data as described in our Privacy Policy. By using Umamin, you consent to such collection and use.
29 | - **Account Deletion:** You can delete your account and all associated data through the settings.
30 |
31 | ## 6. Intellectual Property
32 |
33 | - **Ownership:** Umamin and its original content, features, and functionality are and will remain the exclusive property of Omsimos and its licensors. The platform is protected by copyright, trademark, and other laws of both the Philippines and foreign countries.
34 |
35 | ## 7. Disclaimers and Limitation of Liability
36 |
37 | - **Use at Your Own Risk:** Your use of Umamin is at your sole risk. The platform is provided on an "AS IS" and "AS AVAILABLE" basis.
38 | - **No Warranty:** We do not warrant that the platform will be uninterrupted or error-free.
39 | - **Limitation of Liability:** In no event shall Omsimos, its affiliates, or its licensors be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses, resulting from your use of Umamin.
40 |
41 | ## 8. Open-Source License
42 |
43 | - Umamin is licensed under the GNU General Public License v3.0 (GPL-3.0). You may obtain a copy of the License at [https://www.gnu.org/licenses/gpl-3.0.html](https://www.gnu.org/licenses/gpl-3.0.html).
44 |
45 | ## 9. Changes to the Terms of Service
46 |
47 | - We may update our Terms of Service from time to time. We will notify you of any changes by posting the new Terms of Service on our platform. You are advised to review these Terms periodically for any changes.
48 |
49 | ## 10. Governing Law
50 |
51 | - These Terms shall be governed and construed in accordance with the laws of the Philippines, without regard to its conflict of law provisions.
52 |
53 | ## 11. Contact Us
54 |
55 | If you have any questions about these Terms of Service, please contact us at umamin@omsimos.com.
56 |
--------------------------------------------------------------------------------
/apps/www/app/settings/components/password-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useAsyncRateLimitedCallback } from "@tanstack/react-pacer/async-rate-limiter";
4 | import { useMutation, useQueryClient } from "@tanstack/react-query";
5 | import { KeyIcon } from "lucide-react";
6 | import { toast } from "sonner";
7 | import type * as z from "zod";
8 | import { updatePasswordAction } from "@/app/actions/user";
9 | import { useAppForm } from "@/hooks/form";
10 | import { passwordFormSchema } from "@/types/user";
11 |
12 | export function PasswordForm({
13 | passwordHash,
14 | }: {
15 | passwordHash?: string | null;
16 | }) {
17 | const queryClient = useQueryClient();
18 |
19 | const rateLimitedAction = useAsyncRateLimitedCallback(updatePasswordAction, {
20 | limit: 2,
21 | window: 60000, // 1 minute
22 | windowType: "sliding",
23 | onReject: () => {
24 | throw new Error("Limit reached. Please wait before trying again.");
25 | },
26 | });
27 |
28 | const mutation = useMutation({
29 | mutationFn: async (values: z.infer) => {
30 | const res = await rateLimitedAction(values);
31 |
32 | if (res?.error) {
33 | throw new Error(res.error);
34 | }
35 | },
36 | onSuccess: async () => {
37 | form.reset();
38 | toast.success("Password updated");
39 | await queryClient.invalidateQueries({ queryKey: ["current_user"] });
40 | },
41 | onError: (err) => {
42 | console.error(err);
43 | toast.error(err.message);
44 | },
45 | });
46 |
47 | const form = useAppForm({
48 | defaultValues: {
49 | currentPassword: "",
50 | newPassword: "",
51 | confirmPassword: "",
52 | },
53 | validators: {
54 | onSubmit: passwordFormSchema,
55 | },
56 | onSubmit: async ({ value }) => {
57 | await mutation.mutateAsync(value);
58 | },
59 | });
60 |
61 | return (
62 | (
73 |
79 | )}
80 | />
81 | )}
82 |
83 | (
86 |
92 | )}
93 | />
94 |
95 | (
98 |
104 | )}
105 | />
106 |
107 |
108 |
109 |
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/apps/www/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { GoogleTagManager } from "@next/third-parties/google";
2 | import type { Metadata, Viewport } from "next";
3 | import { Geist, Geist_Mono } from "next/font/google";
4 | import Script from "next/script";
5 | import { Footer } from "@/components/footer";
6 | import { Navbar } from "@/components/navbar";
7 | import Providers from "./providers";
8 |
9 | import "./globals.css";
10 | import { Suspense } from "react";
11 |
12 | const geistSans = Geist({
13 | variable: "--font-geist-sans",
14 | subsets: ["latin"],
15 | });
16 |
17 | const geistMono = Geist_Mono({
18 | variable: "--font-geist-mono",
19 | subsets: ["latin"],
20 | });
21 |
22 | export const viewport: Viewport = {
23 | width: "device-width",
24 | initialScale: 1,
25 | themeColor: "black",
26 | };
27 |
28 | export const metadata: Metadata = {
29 | metadataBase: new URL("https://www.umamin.link"),
30 | alternates: {
31 | canonical: "/",
32 | },
33 | title: "Umamin — The Platform for Anonymity",
34 | authors: [{ name: "Omsimos Collective" }],
35 | description:
36 | "Umamin is an open-source social platform for sending and receiving encrypted anonymous messages. Ensure your privacy and share your thoughts freely without revealing your identity. Perfect for secure communication and anonymous interactions.",
37 | keywords: [
38 | "anonymous messaging",
39 | "open-source platform",
40 | "encrypted messages",
41 | "privacy",
42 | "anonymity",
43 | ],
44 | openGraph: {
45 | type: "website",
46 | siteName: "Umamin",
47 | url: "https://www.umamin.link",
48 | title: "Umamin — The Platform for Anonymity",
49 | description:
50 | "Umamin is an open-source social platform for sending and receiving encrypted anonymous messages. Ensure your privacy and share your thoughts freely without revealing your identity. Perfect for secure communication and anonymous interactions.",
51 | },
52 | twitter: {
53 | card: "summary_large_image",
54 | title: "Umamin — The Platform for Anonymity",
55 | description:
56 | "Umamin is an open-source social platform for sending and receiving encrypted anonymous messages. Ensure your privacy and share your thoughts freely without revealing your identity. Perfect for secure communication and anonymous interactions.",
57 | },
58 | robots: {
59 | index: true,
60 | follow: true,
61 | googleBot: {
62 | index: true,
63 | follow: true,
64 | noimageindex: false,
65 | "max-video-preview": -1,
66 | "max-image-preview": "large",
67 | "max-snippet": -1,
68 | },
69 | },
70 | };
71 |
72 | export default function RootLayout({
73 | children,
74 | }: Readonly<{
75 | children: React.ReactNode;
76 | }>) {
77 | return (
78 |
79 |
82 |
83 |
84 |
85 |
86 |
87 | {children}
88 |
89 |
90 |
91 |
92 |
93 |
94 | {process.env.NODE_ENV === "production" && (
95 |
101 | )}
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/apps/www/app/to/[username]/components/chat-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useAsyncRateLimitedCallback } from "@tanstack/react-pacer/async-rate-limiter";
4 | import { useMutation } from "@tanstack/react-query";
5 | import { Badge } from "@umamin/ui/components/badge";
6 | import { Button } from "@umamin/ui/components/button";
7 | import { Textarea } from "@umamin/ui/components/textarea";
8 | import { cn } from "@umamin/ui/lib/utils";
9 | import { Loader2Icon, MoonIcon, SendIcon } from "lucide-react";
10 | import { useState } from "react";
11 | import { toast } from "sonner";
12 | import { sendMessageAction } from "@/app/actions/message";
13 | import { ChatList } from "@/components/chat-list";
14 | import { useDynamicTextarea } from "@/hooks/use-dynamic-textarea";
15 | import { formatContent } from "@/lib/utils";
16 | import type { PublicUser } from "@/types/user";
17 |
18 | export function ChatForm({ user }: { user: PublicUser }) {
19 | const [content, setContent] = useState("");
20 | const [message, setMessage] = useState("");
21 |
22 | const inputRef = useDynamicTextarea(content);
23 |
24 | const rateLimitedMessage = useAsyncRateLimitedCallback(sendMessageAction, {
25 | limit: 3,
26 | window: 60000, // 1 minute
27 | windowType: "sliding",
28 | onReject: () => {
29 | throw new Error("Limit reached. Please wait before trying again.");
30 | },
31 | });
32 |
33 | const mutation = useMutation({
34 | mutationFn: async () => {
35 | const res = await rateLimitedMessage({
36 | receiverId: user?.id,
37 | question: user?.question,
38 | content,
39 | });
40 |
41 | if (res.error) {
42 | throw new Error(res.error);
43 | }
44 | },
45 | onSuccess: () => {
46 | setMessage(formatContent(content));
47 | toast.success("Message sent anonymously");
48 | setContent("");
49 | },
50 | onError: (err) => {
51 | console.log(err);
52 | toast.error(err.message);
53 | },
54 | });
55 |
56 | return (
57 |
63 |
64 |
69 |
70 |
71 | {user?.quietMode ? (
72 |
76 | User has enabled quiet mode
77 |
78 | ) : (
79 |
109 | )}
110 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/apps/www/app/settings/components/account-settings.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Alert,
5 | AlertDescription,
6 | AlertTitle,
7 | } from "@umamin/ui/components/alert";
8 | import {
9 | Avatar,
10 | AvatarFallback,
11 | AvatarImage,
12 | } from "@umamin/ui/components/avatar";
13 | import { Button } from "@umamin/ui/components/button";
14 | import { Card, CardHeader } from "@umamin/ui/components/card";
15 | import {
16 | Collapsible,
17 | CollapsibleContent,
18 | CollapsibleTrigger,
19 | } from "@umamin/ui/components/collapsible";
20 | import { Label } from "@umamin/ui/components/label";
21 | import { formatDistanceToNow } from "date-fns";
22 | import {
23 | ChevronsUpDownIcon,
24 | KeyIcon,
25 | ScanFaceIcon,
26 | ShieldAlertIcon,
27 | } from "lucide-react";
28 | import Link from "next/link";
29 | import type { UserWithAccount } from "@/types/user";
30 | import { DangerSettings } from "./danger-settings";
31 | import { PasswordForm } from "./password-form";
32 |
33 | export function AccountSettings({ user }: { user: UserWithAccount }) {
34 | return (
35 |
36 | {!!user.account && (
37 |
38 |
39 |
40 |
41 |
42 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
{user.account.email}
54 |
55 |
56 | Linked{" "}
57 | {formatDistanceToNow(user.account.createdAt, {
58 | addSuffix: true,
59 | })}
60 |
61 |
62 |
63 |
64 |
65 | )}
66 |
67 | {!user?.passwordHash && (
68 |
69 |
70 | Password (optional)
71 |
72 | You can have another way of logging in by adding a password.
73 |
74 |
75 | )}
76 |
77 | {!user.account && (
78 |
79 | Link Account
80 |
81 |
82 | To prevent account loss, you may connect your account with Google.
83 | You can still login with your credentials.
84 |
85 |
90 |
91 |
92 | )}
93 |
94 |
95 |
96 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/apps/www/app/inbox/components/received/reply-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { Button } from "@umamin/ui/components/button";
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogTitle,
7 | } from "@umamin/ui/components/dialog";
8 | import { Textarea } from "@umamin/ui/components/textarea";
9 | import { cn } from "@umamin/ui/lib/utils";
10 | import { formatDistanceToNow } from "date-fns";
11 | import { Loader2Icon, SendIcon } from "lucide-react";
12 | import { useState } from "react";
13 | import { toast } from "sonner";
14 | import { createReplyAction } from "@/app/actions/message";
15 | import { ChatList } from "@/components/chat-list";
16 | import { useDynamicTextarea } from "@/hooks/use-dynamic-textarea";
17 | import type { ReceivedMenuProps } from "./received-card-menu";
18 |
19 | type Props = {
20 | open: boolean;
21 | onOpenChange: (open: boolean) => void;
22 | data: ReceivedMenuProps;
23 | };
24 |
25 | export function ReplyDialog(props: Props) {
26 | const [content, setContent] = useState("");
27 | const [updatedAt, setUpdatedAt] = useState(props.data.updatedAt);
28 | const [reply, setReply] = useState(props.data.reply ?? "");
29 | const inputRef = useDynamicTextarea(content);
30 |
31 | const mutation = useMutation({
32 | mutationFn: async () => {
33 | const res = await createReplyAction({
34 | messageId: props.data.id,
35 | content,
36 | });
37 |
38 | if (res.error) {
39 | throw new Error(res.error);
40 | }
41 | },
42 | onSuccess: () => {
43 | toast.success("Reply sent");
44 | setReply(content);
45 | setContent("");
46 | setUpdatedAt(new Date());
47 | },
48 | onError: (err) => {
49 | console.error(err);
50 | toast.error("Failed to send reply. Please try again.");
51 | },
52 | });
53 |
54 | return (
55 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/apps/www/app/api/messages/route.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@umamin/db";
2 | import { messageTable, type SelectMessage } from "@umamin/db/schema/message";
3 | import { userTable } from "@umamin/db/schema/user";
4 | import { aesDecrypt } from "@umamin/encryption";
5 | import { and, desc, eq, lt, or } from "drizzle-orm";
6 | import { cacheLife } from "next/cache";
7 | import type { NextRequest } from "next/server";
8 | import { getSession } from "@/lib/auth";
9 | import type { PublicUser } from "@/types/user";
10 |
11 | export async function GET(req: NextRequest) {
12 | try {
13 | const { session } = await getSession();
14 |
15 | if (!session) {
16 | return Response.json({ error: "Unauthorized" }, { status: 401 });
17 | }
18 |
19 | const searchParams = req.nextUrl.searchParams;
20 | const cursor = searchParams.get("cursor");
21 | const typeParam = searchParams.get("type");
22 | const type = typeParam === "sent" ? "sent" : "received";
23 |
24 | const result = await (async () => {
25 | "use cache: private";
26 | cacheLife({ revalidate: 30 });
27 |
28 | // biome-ignore lint/suspicious/noImplicitAnyLet: drizzle cursor condition
29 | let cursorCondition;
30 |
31 | if (cursor) {
32 | const sep = cursor.indexOf(".");
33 | if (sep > 0) {
34 | const ms = Number(cursor.slice(0, sep));
35 | const cursorId = cursor.slice(sep + 1);
36 | const cursorDate = new Date(ms);
37 | cursorCondition = or(
38 | lt(messageTable.createdAt, cursorDate),
39 | and(
40 | eq(messageTable.createdAt, cursorDate),
41 | lt(messageTable.id, cursorId),
42 | ),
43 | );
44 | }
45 | }
46 |
47 | const messageId =
48 | type === "received" ? messageTable.receiverId : messageTable.senderId;
49 |
50 | const baseCondition = eq(messageId, session.userId);
51 | const whereCondition = cursorCondition
52 | ? and(cursorCondition, baseCondition)
53 | : baseCondition;
54 |
55 | const rows = await db
56 | .select({
57 | message: messageTable,
58 | receiver: {
59 | id: userTable.id,
60 | username: userTable.username,
61 | displayName: userTable.displayName,
62 | imageUrl: userTable.imageUrl,
63 | quietMode: userTable.quietMode,
64 | },
65 | })
66 | .from(messageTable)
67 | .leftJoin(userTable, eq(messageTable.receiverId, userTable.id))
68 | .where(whereCondition)
69 | .orderBy(desc(messageTable.createdAt), desc(messageTable.id))
70 | .limit(20);
71 |
72 | const data = rows
73 | .filter((row) => row.receiver !== null)
74 | .map(({ message, receiver }) => ({
75 | ...message,
76 | receiver,
77 | }));
78 |
79 | const messagesData = await Promise.all(
80 | data.map(async (msg) => {
81 | let content: string;
82 | let reply: string | null = null;
83 |
84 | try {
85 | content = await aesDecrypt(msg.content);
86 | } catch {
87 | content = msg.content;
88 | }
89 |
90 | if (msg.reply) {
91 | try {
92 | const decryptedReply = await aesDecrypt(msg.reply);
93 | if (decryptedReply) {
94 | reply = decryptedReply;
95 | }
96 | } catch {
97 | reply = msg.reply;
98 | }
99 | }
100 |
101 | return {
102 | ...msg,
103 | content,
104 | reply,
105 | };
106 | }),
107 | );
108 |
109 | return {
110 | messages: messagesData as (SelectMessage & {
111 | receiver: PublicUser;
112 | })[],
113 | nextCursor:
114 | messagesData.length === 20
115 | ? `${messagesData[messagesData.length - 1].createdAt?.getTime()}.${
116 | messagesData[messagesData.length - 1].id
117 | }`
118 | : null,
119 | };
120 | })();
121 |
122 | return Response.json(result);
123 | } catch (error) {
124 | console.error("Error fetching messages:", error);
125 | return Response.json({ error: "Internal server error" }, { status: 500 });
126 | }
127 | }
128 |
--------------------------------------------------------------------------------