├── .eslintrc.json ├── app ├── favicon.ico ├── fonts │ ├── GeistVF.woff │ └── GeistMonoVF.woff ├── api │ ├── logger.ts │ └── validate-contact │ │ └── route.ts ├── page.tsx ├── layout.tsx └── tiptap.css ├── public ├── sound-effects │ ├── sent.m4a │ ├── unread.m4a │ ├── reaction.m4a │ └── received.m4a ├── vercel.svg ├── reactions │ ├── circle.svg │ ├── heart-white.svg │ ├── heart-gray.svg │ ├── like-gray.svg │ ├── like-white.svg │ ├── left-light-heart-overlay.svg │ ├── left-dark-heart-overlay.svg │ ├── left-dark-heart.svg │ ├── left-light-heart.svg │ ├── left-dark-blue-heart.svg │ ├── left-light-blue-heart.svg │ ├── left-pinned-light-heart.svg │ ├── right-dark-heart-overlay.svg │ ├── right-light-heart-overlay.svg │ ├── right-dark-heart.svg │ ├── right-light-heart.svg │ ├── right-dark-blue-heart.svg │ ├── right-light-blue-heart.svg │ ├── right-pinned-light-heart.svg │ ├── question-white.svg │ ├── question-gray.svg │ ├── dislike-white.svg │ ├── dislike-gray.svg │ ├── left-dark-like-overlay.svg │ ├── left-light-like-overlay.svg │ ├── left-dark-like.svg │ ├── left-light-like.svg │ ├── left-dark-blue-like.svg │ ├── left-light-blue-like.svg │ ├── left-pinned-light-like.svg │ ├── right-dark-like-overlay.svg │ ├── right-light-like-overlay.svg │ ├── right-dark-blue-like.svg │ ├── right-dark-like.svg │ ├── right-light-blue-like.svg │ ├── right-light-like.svg │ ├── right-pinned-light-like.svg │ ├── left-dark-question-overlay.svg │ ├── left-light-question-overlay.svg │ ├── left-dark-question.svg │ ├── left-dark-blue-question.svg │ ├── left-light-blue-question.svg │ ├── left-light-question.svg │ ├── left-pinned-light-question.svg │ ├── right-dark-question-overlay.svg │ ├── right-light-question-overlay.svg │ ├── right-dark-question.svg │ ├── right-light-question.svg │ ├── right-dark-blue-question.svg │ ├── right-light-blue-question.svg │ ├── right-pinned-light-question.svg │ ├── emphasize-white.svg │ ├── emphasize-gray.svg │ ├── left-dark-dislike-overlay.svg │ ├── left-light-dislike-overlay.svg │ ├── left-dark-blue-dislike.svg │ ├── left-dark-dislike.svg │ ├── left-light-blue-dislike.svg │ ├── left-light-dislike.svg │ ├── left-pinned-light-dislike.svg │ ├── right-dark-dislike-overlay.svg │ ├── right-light-dislike-overlay.svg │ ├── right-dark-dislike.svg │ ├── right-light-dislike.svg │ ├── right-dark-blue-dislike.svg │ ├── right-light-blue-dislike.svg │ ├── right-pinned-light-dislike.svg │ ├── left-dark-emphasize-overlay.svg │ ├── left-light-emphasize-overlay.svg │ ├── left-light-blue-emphasize.svg │ ├── left-dark-blue-emphasize.svg │ └── left-dark-emphasize.svg ├── typing-bubbles │ ├── typing-dark.svg │ ├── typing-light.svg │ ├── typing-blue.svg │ ├── chat-typing-light.svg │ └── chat-typing-dark.svg ├── window.svg ├── file.svg ├── message-bubbles │ ├── left-bubble-light.svg │ ├── left-bubble-dark.svg │ ├── right-bubble-light.svg │ └── right-bubble-dark.svg ├── globe.svg └── next.svg ├── config └── site.ts ├── postcss.config.mjs ├── lib ├── utils.ts ├── contacts.ts └── sound-effects.ts ├── next.config.ts ├── components ├── theme-provider.tsx ├── ui │ ├── label.tsx │ ├── input.tsx │ ├── toaster.tsx │ ├── switch.tsx │ ├── popover.tsx │ ├── avatar.tsx │ └── button.tsx ├── theme-toggle.tsx ├── swipe-actions.tsx ├── search-bar.tsx └── nav.tsx ├── components.json ├── evals └── chat.eval.ts ├── README.md ├── .gitignore ├── tsconfig.json ├── types └── index.ts ├── package.json └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanagoyal/messages/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanagoyal/messages/HEAD/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanagoyal/messages/HEAD/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /public/sound-effects/sent.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanagoyal/messages/HEAD/public/sound-effects/sent.m4a -------------------------------------------------------------------------------- /public/sound-effects/unread.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanagoyal/messages/HEAD/public/sound-effects/unread.m4a -------------------------------------------------------------------------------- /public/sound-effects/reaction.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanagoyal/messages/HEAD/public/sound-effects/reaction.m4a -------------------------------------------------------------------------------- /public/sound-effects/received.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alanagoyal/messages/HEAD/public/sound-effects/received.m4a -------------------------------------------------------------------------------- /config/site.ts: -------------------------------------------------------------------------------- 1 | export const siteConfig = { 2 | title: "alana goyal | messages", 3 | url: process.env.NEXT_PUBLIC_VERCEL_URL, 4 | }; -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/logger.ts: -------------------------------------------------------------------------------- 1 | import { initLogger } from "braintrust"; 2 | 3 | export const logger = initLogger({ 4 | projectName: "messages", 5 | apiKey: process.env.BRAINTRUST_API_KEY, 6 | }); -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | basePath: "/messages", 5 | }; 6 | 7 | module.exports = nextConfig; 8 | 9 | export default nextConfig; 10 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import App from "@/components/app"; 4 | 5 | export default function Home() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /public/reactions/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/typing-bubbles/typing-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/typing-bubbles/typing-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /public/typing-bubbles/typing-blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /evals/chat.eval.ts: -------------------------------------------------------------------------------- 1 | import { Eval, initDataset } from "braintrust"; 2 | import { LLMClassifierFromTemplate } from "autoevals"; 3 | 4 | const noRepeat = LLMClassifierFromTemplate({ 5 | name: "No repetition", 6 | promptTemplate: "Is this chat conversation repetetive? (Y/N)\n\n{{output}}", 7 | choiceScores: { Y: 0, N: 1 }, 8 | useCoT: true, 9 | }); 10 | 11 | Eval("dialogue", { 12 | data: initDataset("dialogue", { dataset: "conversations" }), 13 | task: (input) => { 14 | return JSON.stringify(input); 15 | }, 16 | scores: [noRepeat], 17 | }); 18 | -------------------------------------------------------------------------------- /public/typing-bubbles/chat-typing-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/typing-bubbles/chat-typing-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [messages](https://alanagoyal.com/messages) 2 | 3 | i'm obsessed with re-creating apple products. this one is an imessage-inspired website that allows you to create autonomous group chats with ai personas. 4 | 5 | ## clone the repo 6 | 7 | `git clone https://github.com/alanagoyal/messages` 8 | 9 | ## install dependencies 10 | 11 | `npm install` 12 | 13 | ## api keys 14 | 15 | you'll need a [braintrust.dev](https://braintrust.dev) api key. add it to the `.env.local` file as `BRAINTRUST_API_KEY` 16 | 17 | ## run the app 18 | 19 | `npm run dev` 20 | 21 | ## deploy 22 | 23 | deployed to [vercel.com](https://vercel.com) 24 | -------------------------------------------------------------------------------- /.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 | 32 | # env files (can opt-in for committing if needed) 33 | .env* 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | -------------------------------------------------------------------------------- /public/message-bubbles/left-bubble-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/message-bubbles/left-bubble-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /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": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /public/message-bubbles/right-bubble-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/message-bubbles/right-bubble-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /lib/contacts.ts: -------------------------------------------------------------------------------- 1 | import { InitialContact } from "@/data/initial-contacts"; 2 | 3 | const CONTACTS_KEY = "user_contacts"; 4 | 5 | export function getUserContacts(): InitialContact[] { 6 | if (typeof window === "undefined") return []; 7 | const contacts = localStorage.getItem(CONTACTS_KEY); 8 | return contacts ? JSON.parse(contacts) : []; 9 | } 10 | 11 | export function addUserContact(name: string): InitialContact[] { 12 | const contacts = getUserContacts(); 13 | const newContact: InitialContact = { 14 | name, 15 | title: "Custom Contact" 16 | }; 17 | 18 | // Check if contact already exists 19 | if (!contacts.some(contact => contact.name.toLowerCase() === name.toLowerCase())) { 20 | contacts.push(newContact); 21 | localStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts)); 22 | } 23 | 24 | return contacts; 25 | } 26 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useTheme } from "next-themes" 5 | import { Button } from "@/components/ui/button" 6 | import { Icons } from "./icons" 7 | 8 | export function ThemeToggle() { 9 | const { setTheme, theme } = useTheme() 10 | 11 | return ( 12 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | id: string; 3 | content: string; 4 | htmlContent?: string; 5 | sender: "me" | "system" | string; 6 | timestamp: string; 7 | type?: "silenced"; 8 | mentions?: { id: string; name: string; }[]; 9 | reactions?: Reaction[]; 10 | } 11 | 12 | export interface Conversation { 13 | id: string; 14 | name?: string; 15 | recipients: Recipient[]; 16 | messages: Message[]; 17 | lastMessageTime: string; 18 | unreadCount: number; 19 | pinned?: boolean; 20 | isTyping?: boolean; 21 | hideAlerts?: boolean; 22 | } 23 | 24 | export interface Recipient { 25 | id: string; 26 | name: string; 27 | avatar?: string; 28 | bio?: string; 29 | title?: string; 30 | } 31 | 32 | export type ReactionType = 'heart' | 'like' | 'dislike' | 'laugh' | 'emphasize' | 'question'; 33 | 34 | export interface Reaction { 35 | type: ReactionType; 36 | sender: string; 37 | timestamp: string; 38 | } -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/validate-contact/route.ts: -------------------------------------------------------------------------------- 1 | import { initLogger, invoke, wrapTraced } from "braintrust"; 2 | import { NextResponse } from 'next/server'; 3 | 4 | initLogger({ 5 | projectName: "messages", 6 | apiKey: process.env.BRAINTRUST_API_KEY, 7 | asyncFlush: true, 8 | }); 9 | 10 | export async function POST(req: Request) { 11 | try { 12 | const body = await req.json(); 13 | const { name } = body; 14 | const data = await handleRequest(name); 15 | return NextResponse.json(data); 16 | 17 | } catch (error) { 18 | console.error("Error in API route:", error); 19 | return NextResponse.json({ error: String(error) }, { status: 500 }); 20 | } 21 | } 22 | 23 | const handleRequest = wrapTraced(async function handleRequest(name: string) { 24 | try { 25 | const result = await invoke({ 26 | projectName: "messages", 27 | slug: "validate-name-317c", 28 | input: { 29 | name, 30 | }, 31 | stream: false, 32 | }); 33 | return result; 34 | } catch (error) { 35 | console.error("Error in handleRequest:", error); 36 | throw error; 37 | } 38 | }); -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/reactions/heart-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/reactions/heart-gray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )) 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 34 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { ThemeProvider } from "@/components/theme-provider"; 3 | import { siteConfig } from "@/config/site"; 4 | import "./globals.css"; 5 | import { Toaster } from "@/components/ui/toaster"; 6 | 7 | export const metadata: Metadata = { 8 | title: siteConfig.title, 9 | openGraph: { 10 | title: siteConfig.title, 11 | siteName: siteConfig.title, 12 | url: siteConfig.url, 13 | images: [ 14 | { 15 | url: "/messages/api/og", 16 | width: 1200, 17 | height: 630, 18 | }, 19 | ], 20 | }, 21 | twitter: { 22 | card: "summary_large_image", 23 | title: siteConfig.title, 24 | images: ["/messages/api/og"], 25 | }, 26 | }; 27 | 28 | export default function RootLayout({ 29 | children, 30 | }: { 31 | children: React.ReactNode; 32 | }) { 33 | return ( 34 | 35 | 36 | 40 | 41 | 42 | 48 | {children} 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/swipe-actions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Icons } from './icons'; 3 | 4 | interface SwipeActionsProps { 5 | isOpen: boolean; 6 | onDelete: () => void; 7 | onPin: () => void; 8 | onHideAlerts: () => void; 9 | isPinned?: boolean; 10 | hideAlerts?: boolean; 11 | } 12 | 13 | export function SwipeActions({ 14 | isOpen, 15 | onDelete, 16 | onPin, 17 | onHideAlerts, 18 | isPinned = false, 19 | hideAlerts = false, 20 | }: SwipeActionsProps) { 21 | return ( 22 |
29 | 35 | 41 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /public/reactions/like-gray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/like-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-light-heart-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-dark-heart-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-dark-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-light-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-dark-blue-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-light-blue-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-pinned-light-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/tiptap.css: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | > * + * { 3 | margin-top: 0.75em; 4 | } 5 | 6 | &:focus { 7 | outline: none; 8 | } 9 | 10 | @apply text-foreground; 11 | } 12 | 13 | .suggestion { 14 | color: #6b7280; /* gray-500 */ 15 | } 16 | 17 | /* Mention node styling */ 18 | .ProseMirror .mention-node { 19 | color: #0A7CFF; 20 | font-weight: 500; 21 | position: relative; 22 | display: inline-block; 23 | background: #0A7CFF -webkit-gradient(linear, 100% 0, 0 0, from(#0A7CFF), color-stop(0.5, #ffffff), to(#0A7CFF)); 24 | background-position: -4rem top; 25 | background-repeat: no-repeat; 26 | -webkit-background-clip: text; 27 | -webkit-text-fill-color: transparent; 28 | background-size: 4rem 100%; 29 | transform-origin: bottom; 30 | } 31 | 32 | .ProseMirror .mention-node:not(.shimmer-done) { 33 | animation: 34 | shimmer 2.2s cubic-bezier(0.4, 0.0, 0.2, 1) forwards, 35 | bounce 2.2s cubic-bezier(0.4, 0.0, 0.2, 1) forwards; 36 | animation-iteration-count: 1, 1; 37 | animation-play-state: running; 38 | animation-fill-mode: forwards; 39 | } 40 | 41 | .ProseMirror .mention-node.shimmer-done { 42 | animation: none; 43 | transform: translateY(0); 44 | } 45 | 46 | @keyframes shimmer { 47 | 0% { 48 | background-position: -4rem top; 49 | } 50 | 100% { 51 | background-position: 12.5rem top; 52 | } 53 | } 54 | 55 | @keyframes bounce { 56 | 0% { 57 | transform: translateY(0) scale(1); 58 | } 59 | 30% { 60 | transform: translateY(-1px) scale(1.02); 61 | } 62 | 100% { 63 | transform: translateY(0) scale(1); 64 | } 65 | } 66 | 67 | .ProseMirror .mention-node::after { 68 | display: none; 69 | } 70 | 71 | /* Hide the default prosemirror menu */ 72 | .ProseMirror-menubar { 73 | display: none; 74 | } 75 | 76 | /* Placeholder styling */ 77 | .ProseMirror p.is-editor-empty:first-child::before { 78 | @apply text-muted-foreground; 79 | content: attr(data-placeholder); 80 | float: left; 81 | height: 0; 82 | pointer-events: none; 83 | } 84 | -------------------------------------------------------------------------------- /public/reactions/right-dark-heart-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-light-heart-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-dark-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-light-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-dark-blue-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-light-blue-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-pinned-light-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/question-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socratichat", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ai-sdk/openai": "^1.0.4", 13 | "@emoji-mart/data": "^1.2.1", 14 | "@emoji-mart/react": "^1.1.1", 15 | "@fortawesome/fontawesome-svg-core": "^6.7.1", 16 | "@fortawesome/free-solid-svg-icons": "^6.7.1", 17 | "@fortawesome/react-fontawesome": "^0.2.2", 18 | "@radix-ui/react-avatar": "^1.1.1", 19 | "@radix-ui/react-context-menu": "^2.2.2", 20 | "@radix-ui/react-dialog": "^1.1.4", 21 | "@radix-ui/react-label": "^2.1.0", 22 | "@radix-ui/react-popover": "^1.1.2", 23 | "@radix-ui/react-scroll-area": "^1.2.1", 24 | "@radix-ui/react-slot": "^1.1.0", 25 | "@radix-ui/react-switch": "^1.1.2", 26 | "@radix-ui/react-toast": "^1.2.3", 27 | "@tiptap/extension-mention": "^2.10.3", 28 | "@tiptap/extension-placeholder": "^2.10.3", 29 | "@tiptap/pm": "^2.10.3", 30 | "@tiptap/react": "^2.10.3", 31 | "@tiptap/starter-kit": "^2.10.3", 32 | "@types/uuid": "^10.0.0", 33 | "ai": "^4.0.3", 34 | "autoevals": "^0.0.115", 35 | "braintrust": "^0.0.171", 36 | "class-variance-authority": "^0.7.0", 37 | "clsx": "^2.1.1", 38 | "cmdk": "^1.0.0", 39 | "date-fns": "^4.1.0", 40 | "lucide-react": "^0.460.0", 41 | "next": "15.0.5", 42 | "next-themes": "^0.4.3", 43 | "openai": "^4.73.0", 44 | "react": "18.2.0", 45 | "react-dom": "18.2.0", 46 | "react-swipeable": "^7.0.2", 47 | "tailwind-merge": "^2.5.4", 48 | "tailwindcss-animate": "^1.0.7", 49 | "uuid": "^11.0.3", 50 | "vaul": "^1.1.2" 51 | }, 52 | "devDependencies": { 53 | "@types/node": "^20", 54 | "@types/react": "^18", 55 | "@types/react-dom": "^18", 56 | "eslint": "^8", 57 | "eslint-config-next": "15.0.5", 58 | "postcss": "^8", 59 | "tailwindcss": "^3.4.1", 60 | "typescript": "^5" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/reactions/question-gray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | safelist: ["mention-node"], 11 | theme: { 12 | extend: { 13 | colors: { 14 | background: "hsl(var(--background))", 15 | foreground: "hsl(var(--foreground))", 16 | card: { 17 | DEFAULT: "hsl(var(--card))", 18 | foreground: "hsl(var(--card-foreground))", 19 | }, 20 | popover: { 21 | DEFAULT: "hsl(var(--popover))", 22 | foreground: "hsl(var(--popover-foreground))", 23 | }, 24 | primary: { 25 | DEFAULT: "hsl(var(--primary))", 26 | foreground: "hsl(var(--primary-foreground))", 27 | }, 28 | secondary: { 29 | DEFAULT: "hsl(var(--secondary))", 30 | foreground: "hsl(var(--secondary-foreground))", 31 | }, 32 | muted: { 33 | DEFAULT: "hsl(var(--muted))", 34 | foreground: "hsl(var(--muted-foreground))", 35 | }, 36 | accent: { 37 | DEFAULT: "hsl(var(--accent))", 38 | foreground: "hsl(var(--accent-foreground))", 39 | }, 40 | destructive: { 41 | DEFAULT: "hsl(var(--destructive))", 42 | foreground: "hsl(var(--destructive-foreground))", 43 | }, 44 | border: "hsl(var(--border))", 45 | input: "hsl(var(--input))", 46 | ring: "hsl(var(--ring))", 47 | chart: { 48 | "1": "hsl(var(--chart-1))", 49 | "2": "hsl(var(--chart-2))", 50 | "3": "hsl(var(--chart-3))", 51 | "4": "hsl(var(--chart-4))", 52 | "5": "hsl(var(--chart-5))", 53 | }, 54 | }, 55 | borderRadius: { 56 | lg: "var(--radius)", 57 | md: "calc(var(--radius) - 2px)", 58 | sm: "calc(var(--radius) - 4px)", 59 | }, 60 | }, 61 | }, 62 | plugins: [require("tailwindcss-animate")], 63 | } satisfies Config; 64 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /public/reactions/dislike-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/dislike-gray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /components/search-bar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { Icons } from "./icons"; 3 | 4 | interface SearchBarProps { 5 | value: string; 6 | onChange: (value: string) => void; 7 | } 8 | 9 | export function SearchBar({ value, onChange }: SearchBarProps) { 10 | const justBlurred = useRef(false); 11 | 12 | useEffect(() => { 13 | const handleGlobalEscape = (e: KeyboardEvent) => { 14 | if (e.key === "Escape") { 15 | const searchInput = document.querySelector( 16 | 'input[placeholder="Search"]' 17 | ); 18 | if ( 19 | document.activeElement !== searchInput && 20 | value && 21 | !justBlurred.current 22 | ) { 23 | onChange(""); 24 | } 25 | justBlurred.current = false; 26 | } 27 | }; 28 | 29 | window.addEventListener("keydown", handleGlobalEscape); 30 | return () => window.removeEventListener("keydown", handleGlobalEscape); 31 | }, [value, onChange]); 32 | 33 | return ( 34 |
35 |
36 | 37 | onChange(e.target.value)} 41 | onKeyDown={(e) => { 42 | if (e.key === "Escape") { 43 | e.preventDefault(); 44 | if (document.activeElement === e.currentTarget) { 45 | justBlurred.current = true; 46 | e.currentTarget.blur(); 47 | setTimeout(() => { 48 | justBlurred.current = false; 49 | }, 0); 50 | } 51 | } 52 | }} 53 | placeholder="Search" 54 | className="w-full pl-8 pr-8 py-0.5 rounded-lg text-base sm:text-sm placeholder:text-sm placeholder:text-muted-foreground focus:outline-none bg-[#E8E8E7] dark:bg-[#353533]" 55 | /> 56 | {value && ( 57 | 64 | )} 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /public/reactions/left-dark-like-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-light-like-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-dark-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-light-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-dark-blue-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-light-blue-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-pinned-light-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-dark-like-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-light-like-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-dark-blue-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-dark-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-light-blue-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-light-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-pinned-light-like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-dark-question-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-light-question-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-dark-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-dark-blue-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-light-blue-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-light-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/left-pinned-light-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-dark-question-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-light-question-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-dark-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-light-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-dark-blue-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-light-blue-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/right-pinned-light-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/reactions/emphasize-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/reactions/emphasize-gray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/reactions/left-dark-dislike-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-light-dislike-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-dark-blue-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-dark-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-light-blue-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-light-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/left-pinned-light-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-dark-dislike-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-light-dislike-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-dark-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-light-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-dark-blue-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-light-blue-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/reactions/right-pinned-light-dislike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/nav.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from "./icons"; 2 | import { useEffect } from "react"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | interface NavProps { 6 | onNewChat: () => void; 7 | isMobileView: boolean; 8 | isScrolled?: boolean; 9 | } 10 | 11 | export function Nav({ onNewChat, isMobileView, isScrolled }: NavProps) { 12 | // Keyboard shortcut for creating a new chat 13 | useEffect(() => { 14 | const handleKeyDown = (e: KeyboardEvent) => { 15 | // Don't trigger if typing in an input, if command/meta key is pressed, 16 | // or if the TipTap editor is focused 17 | if ( 18 | document.activeElement?.tagName === "INPUT" || 19 | e.metaKey || 20 | document.querySelector(".ProseMirror")?.contains(document.activeElement) 21 | ) { 22 | return; 23 | } 24 | 25 | if (e.key === "n") { 26 | e.preventDefault(); 27 | onNewChat(); 28 | } 29 | }; 30 | 31 | document.addEventListener("keydown", handleKeyDown); 32 | return () => document.removeEventListener("keydown", handleKeyDown); 33 | }, [onNewChat]); 34 | 35 | return ( 36 | <> 37 |
44 |
45 | 52 | 55 | 58 |
59 | 68 |
69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /lib/sound-effects.ts: -------------------------------------------------------------------------------- 1 | class SoundEffectPlayer { 2 | private static instance: SoundEffectPlayer; 3 | private sentSound: HTMLAudioElement | null = null; 4 | private receivedSound: HTMLAudioElement | null = null; 5 | private unreadSound: HTMLAudioElement | null = null; 6 | private reactionSound: HTMLAudioElement | null = null; 7 | private enabled: boolean = true; 8 | private isMobile: boolean = false; 9 | 10 | private constructor() { 11 | if (typeof window !== 'undefined') { 12 | // Check if device is mobile 13 | this.isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); 14 | 15 | // Get sound preference from localStorage, default to !isMobile if not set 16 | const storedEnabled = localStorage.getItem('soundEnabled'); 17 | this.enabled = storedEnabled !== null ? storedEnabled === 'true' : !this.isMobile; 18 | 19 | this.sentSound = new Audio('/messages/sound-effects/sent.m4a'); 20 | this.receivedSound = new Audio('/messages/sound-effects/received.m4a'); 21 | this.unreadSound = new Audio('/messages/sound-effects/unread.m4a'); 22 | this.reactionSound = new Audio('/messages/sound-effects/reaction.m4a'); 23 | } 24 | } 25 | 26 | public static getInstance(): SoundEffectPlayer { 27 | if (!SoundEffectPlayer.instance) { 28 | SoundEffectPlayer.instance = new SoundEffectPlayer(); 29 | } 30 | return SoundEffectPlayer.instance; 31 | } 32 | 33 | public playSentSound() { 34 | if (this.enabled && typeof window !== 'undefined' && this.sentSound) { 35 | this.sentSound.currentTime = 0; 36 | this.sentSound.play().catch(() => { 37 | // Silently handle autoplay restrictions 38 | }); 39 | } 40 | } 41 | 42 | public playUnreadSound() { 43 | if (this.enabled && typeof window !== 'undefined' && this.unreadSound) { 44 | this.unreadSound.currentTime = 0; 45 | this.unreadSound.play().catch(() => { 46 | // Silently handle autoplay restrictions 47 | }); 48 | } 49 | } 50 | 51 | public playReceivedSound() { 52 | if (this.enabled && typeof window !== 'undefined' && this.receivedSound) { 53 | this.receivedSound.currentTime = 0; 54 | this.receivedSound.play().catch(() => { 55 | // Silently handle autoplay restrictions 56 | }); 57 | } 58 | } 59 | 60 | public playReactionSound() { 61 | if (this.enabled && typeof window !== 'undefined' && this.reactionSound) { 62 | this.reactionSound.currentTime = 0; 63 | this.reactionSound.play().catch(() => { 64 | // Silently handle autoplay restrictions 65 | }); 66 | } 67 | } 68 | 69 | public toggleSound() { 70 | this.enabled = !this.enabled; 71 | if (typeof window !== 'undefined') { 72 | localStorage.setItem('soundEnabled', this.enabled.toString()); 73 | } 74 | } 75 | 76 | public isEnabled(): boolean { 77 | return this.enabled; 78 | } 79 | 80 | public setEnabled(enabled: boolean) { 81 | this.enabled = enabled; 82 | } 83 | } 84 | 85 | export const soundEffects = SoundEffectPlayer.getInstance(); 86 | -------------------------------------------------------------------------------- /public/reactions/left-dark-emphasize-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/reactions/left-light-emphasize-overlay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/reactions/left-light-blue-emphasize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/reactions/left-dark-blue-emphasize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/reactions/left-dark-emphasize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | --------------------------------------------------------------------------------