├── .env.example ├── .eslintrc.json ├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── prettier.xml ├── shark-chat-js.iml └── vcs.xml ├── .vscode └── shark-chat.code-workspace ├── LICENSE ├── README.md ├── apps └── web │ ├── app │ ├── (dashboard) │ │ ├── (app) │ │ │ ├── chat │ │ │ │ ├── [group] │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── settings │ │ │ │ │ │ ├── danger.tsx │ │ │ │ │ │ ├── info.tsx │ │ │ │ │ │ ├── invite.tsx │ │ │ │ │ │ ├── members.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── use-group.ts │ │ │ │ └── layout.tsx │ │ │ ├── dm │ │ │ │ └── [channel] │ │ │ │ │ └── page.tsx │ │ │ ├── emotes │ │ │ │ ├── item.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.client.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── settings │ │ │ │ ├── page.tsx │ │ │ │ └── update-info.tsx │ │ ├── invite │ │ │ └── [invite] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ ├── layout.client.tsx │ │ └── layout.tsx │ ├── api │ │ ├── ably │ │ │ └── auth │ │ │ │ └── route.ts │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── auth │ │ └── signin │ │ │ ├── page.client.tsx │ │ │ └── page.tsx │ ├── favicon.ico │ ├── info │ │ └── page.tsx │ ├── layout.tsx │ ├── opengraph-image.png │ └── theme.tsx │ ├── components │ ├── BannerImage.tsx │ ├── ThemeSwitch.tsx │ ├── chat │ │ ├── AttachmentItem.tsx │ │ ├── ChatView.tsx │ │ ├── MessageList.tsx │ │ ├── Sendbar.tsx │ │ ├── TypingIndicator.tsx │ │ ├── message │ │ │ ├── atom.tsx │ │ │ ├── edit.tsx │ │ │ ├── embed.tsx │ │ │ ├── index.tsx │ │ │ ├── markdown.tsx │ │ │ ├── reference.tsx │ │ │ └── sending.tsx │ │ ├── scroll.ts │ │ └── use-items.ts │ ├── input │ │ ├── ImagePicker.tsx │ │ └── UniqueNameInput.tsx │ ├── layout │ │ ├── Breadcrumbs.tsx │ │ ├── Navbar.tsx │ │ └── Sidebar.tsx │ ├── menu │ │ └── DirectMessageMenu.tsx │ └── modal │ │ ├── BoardingModal.tsx │ │ ├── CreateEmoteModal.tsx │ │ ├── CreateGroupModal.tsx │ │ ├── GenerateTextModal.tsx │ │ ├── JoinGroupModal.tsx │ │ └── UserProfileModal.tsx │ ├── middleware.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── public │ └── icon.png │ ├── styles │ └── globals.css │ ├── tailwind.config.js │ ├── tsconfig.json │ └── utils │ ├── ably │ └── client.tsx │ ├── auth.tsx │ ├── cloudinary-loader.ts │ ├── contexts │ └── group-context.ts │ ├── get-base-url.ts │ ├── handlers │ ├── chat.tsx │ ├── group.tsx │ ├── private.tsx │ └── shared.ts │ ├── hooks │ ├── mutations │ │ ├── send-message.ts │ │ ├── update-group-info.ts │ │ ├── update-profile.ts │ │ └── upload.ts │ ├── use-callback-ref.ts │ └── use-profile.ts │ ├── stores │ ├── chat.ts │ └── page.ts │ ├── trpc │ └── index.ts │ └── types.ts ├── dev └── compose.yaml ├── document └── image.png ├── package.json ├── packages ├── config │ ├── package.json │ ├── tsconfig-base.json │ ├── tsconfig-lib.json │ └── tsconfig-next.json ├── db │ ├── client.ts │ ├── drizzle.config.ts │ ├── package.json │ ├── schema.ts │ ├── tsconfig.json │ └── utils │ │ └── index.ts ├── server │ ├── ably │ │ ├── index.ts │ │ └── schema.ts │ ├── auth │ │ ├── index.ts │ │ └── nextauth-adapter.ts │ ├── cloudinary.ts │ ├── context.ts │ ├── eden.ts │ ├── inworld.ts │ ├── package.json │ ├── redis │ │ ├── client.ts │ │ ├── last-read.ts │ │ └── ratelimit.ts │ ├── routers │ │ ├── _app.ts │ │ ├── account.ts │ │ ├── chat.ts │ │ ├── dm.ts │ │ ├── emotes.ts │ │ ├── group │ │ │ ├── group.ts │ │ │ ├── invite.ts │ │ │ └── members.ts │ │ └── upload.ts │ ├── trpc.ts │ ├── tsconfig.json │ └── utils │ │ ├── messages.ts │ │ ├── og-meta.ts │ │ └── permissions.ts ├── shared │ ├── ably.ts │ ├── common.ts │ ├── media │ │ ├── format.ts │ │ └── timestamp.ts │ ├── package.json │ ├── schema │ │ ├── chat.ts │ │ ├── group.ts │ │ └── user.ts │ ├── tsconfig.json │ └── types.ts └── ui │ ├── components │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── dropdown.tsx │ ├── image-skeleton.tsx │ ├── input.tsx │ ├── popover.tsx │ ├── select.tsx │ ├── skeleton.tsx │ ├── spinner.tsx │ ├── switch.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ └── tooltip.tsx │ ├── hooks │ ├── use-copy-text.ts │ └── use-mounted.ts │ ├── package.json │ ├── tsconfig.json │ └── utils │ ├── cn.ts │ └── time.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.env.example: -------------------------------------------------------------------------------- 1 | GITHUB_ID="github_oauth2_id" 2 | GITHUB_SECRET="github_oauth2_secret" 3 | 4 | NEXTAUTH_SECRET="nextauth_secret" 5 | NEXTAUTH_URL="http://localhost:3000" 6 | 7 | DATABASE_URL="postresql://somewhere" 8 | 9 | ABLY_API_KEY="ably_realtime_api_key" 10 | 11 | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="cloudinary_cloud_name" 12 | NEXT_PUBLIC_CLOUDINARY_API_KEY="cloudinary_api_key" 13 | CLOUDINARY_API_SECRET="cloudinary_api_secret" 14 | 15 | # Used for Shark AI 16 | # 17 | # INWORLD_KEY="inworld_key" 18 | # INWORLD_SECRET="inworld_secret" 19 | # INWORLD_SCENE="workspaces/somewhere" 20 | 21 | REDIS_URL="https://.upstash.io" 22 | REDIS_TOKEN="upstash_redis_token" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "parserOptions": { 4 | "babelOptions": { 5 | "presets": ["./apps/web/node_modules/next/babel"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | .turbo 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | 15 | # production 16 | build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # For local development only 40 | migrations -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/shark-chat-js.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.vscode/shark-chat.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "../apps/web", 5 | }, 6 | { 7 | "path": "../packages/db", 8 | }, 9 | { 10 | "path": "../packages/ably-builder", 11 | }, 12 | { 13 | "path": "../packages/ui", 14 | }, 15 | { 16 | "path": "../packages/server", 17 | }, 18 | { 19 | "path": "../packages/shared", 20 | }, 21 | { 22 | "path": "../", 23 | }, 24 | ], 25 | "settings": { 26 | "tailwindCSS.experimental.classRegex": [ 27 | ["tv\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 28 | ], 29 | "files.exclude": { 30 | ".turbo": true, 31 | "node_modules": true, 32 | }, 33 | "search.useParentIgnoreFiles": true, 34 | "eslint.enable": false, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shark Chat 2 | 3 | A Chat app built with Trpc, Tailwind CSS, Ably, Redis, Cloudinary, Drizzle ORM, Next.js. 4 | 5 | This repository is a monorepo ([Turborepo](https://turbo.build)). 6 | 7 | ![preview](./document/image.png) 8 | 9 | ## Features 10 | 11 | - Create, Update, Delete Chat Group 12 | - Send, Update, Delete Message 13 | - Markdown (gfm, tables supported), Code highlighting, LaTeX in Messages 14 | - Reference Messages 15 | - Message Embeds (Display open-graph data of links in message) 16 | - Send Images/Files via Message, images zoom-in 17 | - Direct Message with anyone 18 | - View & Kick Group members 19 | - Custom Emotes 20 | - Invite Group members via Invite code or Url 21 | - Upload user avatar, group banner and icon images 22 | - Show notification when new Message received 23 | - AI-Powered Message Writer 24 | - Built-in AI Chatbot (Powered by Inworld) 25 | - Delete Accounts 26 | - Light & Dark Mode 27 | - 100% Typescript 28 | 29 | **Play with it:** https://shark-chat.vercel.app 30 |
31 | **Learn More:** https://shark-chat.vercel.app/info 32 | 33 | ## Play with it Locally 34 | 35 | Shark Chat integrated with many third-party service for supporting wide spectrum of features and work perfectly on serverless environment. 36 | 37 | Thus, you have to register an account for each services in order to setup the project correctly before playing with it locally. 38 | Please fill all environment variables in the [.env.example](/.env.example). 39 | 40 | ### Upstash 41 | 42 | Create a Redis database at their [website](https://upstash.com) and get `REDIS_URL`, `REDIS_TOKEN` from the console. 43 | 44 | ### Ably Realtime 45 | 46 | Create a new project on https://ably.com, paste `ABLY_API_KEY` into environment varibles. 47 | 48 | ### Database 49 | 50 | By default, it uses Drizzle ORM with Neon Serverless Postresql for database. You may use other providers if you prefer. 51 | 52 | Create a Postresql database and get your `DATABASE_URL`. 53 | 54 | ### Cloudinary 55 | 56 | Create a new project on https://cloudinary.com, copy the cloud name, key and API secret. 57 | 58 | ### Next Auth 59 | 60 | Fill `NEXTAUTH_URL` and `NEXTAUTH_SECRET`, read their [docs](https://next-auth.js.org/getting-started/example) for further details. 61 | 62 | Currently, only Github OAuth is supported. Follow [this guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) to setup the OAuth App on Github, generate a `GITHUB_ID` with `GITHUB_SECRET`. 63 | 64 | ### Development Mode 65 | 66 | Run `pnpm run dev` and edit files to see changes. 67 | 68 | ### Build from Source 69 | 70 | This project uses Turborepo and PNPM. 71 | 72 | ```bash 73 | pnpm run build 74 | ``` 75 | 76 | It should be able to deploy on Vercel or any other platforms. 77 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/chat/[group]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { BookmarkIcon } from "lucide-react"; 3 | import { MessageList } from "@/components/chat/MessageList"; 4 | import { Sendbar } from "@/components/chat/Sendbar"; 5 | import { ChatViewport } from "@/components/chat/ChatView"; 6 | import { useGroupContext } from "@/utils/contexts/group-context"; 7 | import { useSession } from "@/utils/auth"; 8 | 9 | export default function Page() { 10 | const { data: session } = useSession(); 11 | const { channel_id: channelId, member, owner_id } = useGroupContext(); 12 | 13 | return ( 14 | <> 15 |
16 | 19 | } /> 20 | 21 |
22 | 23 | 24 | ); 25 | } 26 | 27 | function Welcome() { 28 | return ( 29 |
30 | 31 |

32 | The beginning of this story 33 |

34 |

35 | Let's send your messages here! 36 |

37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/chat/[group]/settings/danger.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { AlertDialog } from "ui/components/alert-dialog"; 3 | import { Button } from "ui/components/button"; 4 | import { showToast } from "@/utils/stores/page"; 5 | import { trpc } from "@/utils/trpc"; 6 | import { useRouter } from "next/navigation"; 7 | import { useState } from "react"; 8 | import { useGroupContext } from "@/utils/contexts/group-context"; 9 | import { useSession } from "@/utils/auth"; 10 | 11 | export function Danger({ group }: { group: string }) { 12 | const { data: session } = useSession(); 13 | const ctx = useGroupContext(); 14 | 15 | return ( 16 |
17 | 18 | {ctx.owner_id === session?.user.id ? : null} 19 |
20 | ); 21 | } 22 | 23 | export function LeaveGroup({ group }: { group: string }) { 24 | const router = useRouter(); 25 | const mutation = trpc.group.leave.useMutation({ 26 | onSuccess: () => { 27 | return router.push("/"); 28 | }, 29 | onError: (error) => { 30 | showToast({ 31 | title: "Failed to leave group", 32 | description: error.message, 33 | }); 34 | }, 35 | }); 36 | 37 | return ( 38 |
39 |

Leave Group

40 |

41 | You can join the group after leaving it 42 |

43 | 51 |
52 | ); 53 | } 54 | 55 | function DeleteGroup({ group }: { group: string }) { 56 | return ( 57 |
58 |

Delete Group

59 |

60 | This action is irreversible and cannot be undone 61 |

62 | 63 |
64 | ); 65 | } 66 | 67 | function DeleteGroupButton({ group }: { group: string }) { 68 | const [open, setOpen] = useState(false); 69 | const deleteMutation = trpc.group.delete.useMutation({ 70 | onSuccess() { 71 | setOpen(false); 72 | }, 73 | }); 74 | 75 | return ( 76 | { 86 | deleteMutation.mutate({ groupId: group }); 87 | e.preventDefault(); 88 | }} 89 | > 90 | Delete Group 91 | 92 | } 93 | > 94 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/chat/[group]/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from "./info"; 2 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/components/tabs"; 3 | import { Danger } from "./danger"; 4 | import Members from "./members"; 5 | import Invite from "./invite"; 6 | 7 | export default function Page({ params }: { params: { group: string } }) { 8 | const groupId = params.group; 9 | 10 | return ( 11 |
12 | 13 | 14 | 15 | Invite 16 | Member 17 | Danger 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/chat/[group]/use-group.ts: -------------------------------------------------------------------------------- 1 | import { trpc } from "@/utils/trpc"; 2 | import { useMemo } from "react"; 3 | import { GroupWithNotifications } from "server/routers/group/group"; 4 | 5 | export function useGroup( 6 | groupId: string | number, 7 | ): GroupWithNotifications | undefined { 8 | const query = trpc.group.all.useQuery(undefined, { enabled: false }); 9 | 10 | return useMemo( 11 | () => query.data?.find((group) => group.id === groupId), 12 | [groupId, query.data], 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/chat/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useParams } from "next/navigation"; 3 | import { useGroup } from "@/app/(dashboard)/(app)/chat/[group]/use-group"; 4 | import { GroupContext } from "@/utils/contexts/group-context"; 5 | import { Spinner } from "ui/components/spinner"; 6 | 7 | export default function Layout({ children }: { children: React.ReactNode }) { 8 | const params = useParams() as { group: string }; 9 | const info = useGroup(params.group); 10 | 11 | return ( 12 | <> 13 | {info ? ( 14 | {children} 15 | ) : ( 16 | 17 | )} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/dm/[channel]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Avatar } from "ui/components/avatar"; 3 | import { trpc } from "@/utils/trpc"; 4 | import { MessageList } from "@/components/chat/MessageList"; 5 | import { ChatViewport } from "@/components/chat/ChatView"; 6 | import { Sendbar } from "@/components/chat/Sendbar"; 7 | import { UserInfo } from "shared/schema/chat"; 8 | 9 | export default function Page({ params }: { params: { channel: string } }) { 10 | const query = trpc.dm.info.useQuery({ channelId: params.channel }); 11 | 12 | return ( 13 | <> 14 |
15 | 16 | : null 21 | } 22 | /> 23 | 24 |
25 | 26 | 27 | ); 28 | } 29 | 30 | function Welcome({ info }: { info: UserInfo }) { 31 | return ( 32 |
33 | 39 |

{info.name}

40 |

41 | Start your conversations with {info.name} 42 |

43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/emotes/item.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { cloudinaryLoader } from "@/utils/cloudinary-loader"; 3 | import type { Emote } from "db/schema"; 4 | import { emotes } from "shared/media/format"; 5 | import { getTimeString } from "ui/utils/time"; 6 | import { Popover } from "ui/components/popover"; 7 | import { Check, Copy } from "lucide-react"; 8 | import { Button, button } from "ui/components/button"; 9 | import { input } from "ui/components/input"; 10 | import { useCopyText } from "ui/hooks/use-copy-text"; 11 | import { Serialize } from "@trpc/server/shared"; 12 | import { trpc } from "@/utils/trpc"; 13 | import { useSession } from "@/utils/auth"; 14 | 15 | export function Item({ emote }: { emote: Serialize }) { 16 | return ( 17 | 20 | {emote.name} 28 | 29 |
30 |

{emote.name}

31 |

32 | {getTimeString(new Date(emote.timestamp))} 33 |

34 |
35 | 36 | } 37 | > 38 | 39 |
40 | ); 41 | } 42 | 43 | function EmoteUsage({ emote }: { emote: Serialize }) { 44 | const { copy, isShow } = useCopyText(); 45 | const { data: session } = useSession(); 46 | const usage = `:${emote.id}:`; 47 | const utils = trpc.useUtils(); 48 | const mutation = trpc.emotes.delete.useMutation({ 49 | onSuccess() { 50 | void utils.emotes.get.invalidate(); 51 | }, 52 | }); 53 | 54 | const isAuthor = session?.user.id === emote.creatorId; 55 | 56 | return ( 57 | <> 58 |

Use This Emote

59 |

60 | Type this in your chat to use it. 61 |

62 |
63 | 64 | 71 |
72 | {isAuthor ? ( 73 | 82 | ) : null} 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/emotes/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { CreateEmoteModal } from "@/components/modal/CreateEmoteModal"; 3 | import { Item } from "./item"; 4 | import { trpc } from "@/utils/trpc"; 5 | import { Spinner } from "ui/components/spinner"; 6 | import { Button } from "ui/components/button"; 7 | 8 | const count = 50; 9 | export default function Page() { 10 | const query = trpc.emotes.get.useInfiniteQuery( 11 | { limit: count }, 12 | { 13 | getNextPageParam: (last, all) => 14 | last.length === 50 ? all.length * count : undefined, 15 | }, 16 | ); 17 | 18 | return ( 19 |
20 |

21 | Upload custom emotes and use them in chat. 22 |

23 |
24 | 25 |
26 |
27 | {query.isLoading && } 28 | {query.data?.pages.flatMap((block) => 29 | block.map((emote) => ), 30 | )} 31 |
32 | {query.hasNextPage ? ( 33 | 40 | ) : null} 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Sidebar from "@/components/layout/Sidebar"; 2 | import React, { ReactNode } from "react"; 3 | import { Nav, Provider } from "./layout.client"; 4 | 5 | export default function Layout({ children }: { children: ReactNode }) { 6 | return ( 7 | 8 |
9 | 10 |
11 |
14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Avatar } from "ui/components/avatar"; 3 | import { Button } from "ui/components/button"; 4 | import { useProfile } from "@/utils/hooks/use-profile"; 5 | import { trpc } from "@/utils/trpc"; 6 | import { signOut } from "next-auth/react"; 7 | import React, { useState } from "react"; 8 | import { ThemeSwitch } from "@/components/ThemeSwitch"; 9 | import { fieldset } from "ui/components/input"; 10 | import { SimpleDialog } from "ui/components/dialog"; 11 | import { AlertDialog } from "ui/components/alert-dialog"; 12 | import { BannerImage } from "@/components/BannerImage"; 13 | import { userBanners } from "shared/media/format"; 14 | import { UpdateProfile } from "@/app/(dashboard)/(app)/settings/update-info"; 15 | 16 | export default function Settings() { 17 | const { status, profile } = useProfile(); 18 | const [edit, setEdit] = useState(false); 19 | 20 | if (status !== "authenticated") return <>; 21 | 22 | return ( 23 |
24 |
25 | 26 | 32 |

{profile.name}

33 |

{profile.email}

34 |
35 | Edit Profile} 40 | > 41 | setEdit(false)} /> 42 | 43 | 44 | 45 |
46 |
47 |
48 |
49 |
50 | 53 |

54 | Change the color theme of UI 55 |

56 |
57 | 58 |
59 | 60 |
61 |
62 | ); 63 | } 64 | 65 | function DangerZone() { 66 | const mutation = trpc.account.delete.useMutation({ 67 | onSuccess() { 68 | return signOut(); 69 | }, 70 | }); 71 | 72 | return ( 73 |
74 |
75 | 78 |

79 | Clear all associated data and profile info. 80 |

81 |
82 | mutation.mutate()}> 87 | Delete 88 | 89 | } 90 | > 91 | 94 | 95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/(app)/settings/update-info.tsx: -------------------------------------------------------------------------------- 1 | import { Serialize } from "shared/types"; 2 | import { User } from "db/schema"; 3 | import { Controller, useForm } from "react-hook-form"; 4 | import { trpc } from "@/utils/trpc"; 5 | import { 6 | type UpdateProfileOptions, 7 | useUpdateProfileMutation, 8 | } from "@/utils/hooks/mutations/update-profile"; 9 | import { ImagePicker } from "@/components/input/ImagePicker"; 10 | import { input } from "ui/components/input"; 11 | import { Button } from "ui/components/button"; 12 | import React from "react"; 13 | import { userBanners } from "shared/media/format"; 14 | 15 | export function UpdateProfile({ 16 | profile, 17 | onCancel, 18 | }: { 19 | profile: Serialize; 20 | onCancel: () => void; 21 | }) { 22 | const form = useForm({ 23 | defaultValues: { 24 | name: profile.name, 25 | }, 26 | }); 27 | const utils = trpc.useUtils(); 28 | const mutation = useUpdateProfileMutation(); 29 | 30 | const onSave = form.handleSubmit((v) => { 31 | mutation.mutate(v, { 32 | onSuccess(data) { 33 | utils.account.get.setData(undefined, data); 34 | onCancel(); 35 | }, 36 | }); 37 | }); 38 | 39 | return ( 40 |
41 | ( 45 | 52 | )} 53 | /> 54 | ( 58 | 64 | )} 65 | /> 66 | 67 |
68 | 71 | 78 |
79 |
80 | 83 | 86 |
87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/invite/[invite]/page.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Group } from "db/schema"; 3 | import { cloudinaryLoader } from "@/utils/cloudinary-loader"; 4 | import Image from "next/image"; 5 | import { groupBanners } from "shared/media/format"; 6 | import { useRouter } from "next/navigation"; 7 | import { trpc } from "@/utils/trpc"; 8 | import { Button } from "ui/components/button"; 9 | import { signIn } from "next-auth/react"; 10 | 11 | export function BannerImage({ group }: { group: Group }) { 12 | if (!group.banner_hash) return <>; 13 | 14 | return ( 15 | Banner 24 | ); 25 | } 26 | 27 | export function InviteButton({ 28 | query, 29 | type, 30 | }: { 31 | type: "code" | "name"; 32 | query: string; 33 | }) { 34 | const router = useRouter(); 35 | const joinMutation = trpc.group.join.useMutation({ 36 | onSuccess: (res) => router.push(`/chat/${res.id}`), 37 | }); 38 | const joinByNameMutation = trpc.group.joinByUniqueName.useMutation({ 39 | onSuccess: (res) => router.push(`/chat/${res.id}`), 40 | }); 41 | 42 | const onClick = () => { 43 | if (type === "code") { 44 | joinMutation.mutate({ code: query }); 45 | } else if (type === "name") { 46 | joinByNameMutation.mutate({ uniqueName: query }); 47 | } 48 | }; 49 | 50 | return ( 51 | 59 | ); 60 | } 61 | 62 | export function LoginButton() { 63 | return ( 64 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/invite/[invite]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Group, groupInvites, groups } from "db/schema"; 2 | import { Avatar } from "ui/components/avatar"; 3 | import { groupIcon } from "shared/media/format"; 4 | import db from "db/client"; 5 | import { eq } from "drizzle-orm"; 6 | import { notFound } from "next/navigation"; 7 | import { Metadata } from "next"; 8 | import { getServerSession } from "next-auth"; 9 | import { BannerImage, InviteButton, LoginButton } from "./page.client"; 10 | 11 | type Data = { 12 | group: Group; 13 | type: "code" | "name"; 14 | query: string; 15 | }; 16 | 17 | export default async function InvitePage({ 18 | params, 19 | }: { 20 | params: { invite: string }; 21 | }) { 22 | const info = await getGroupInfo(params.invite); 23 | 24 | if (info == null) { 25 | notFound(); 26 | } 27 | 28 | const session = await getServerSession(); 29 | const { group, query, type } = info; 30 | 31 | return ( 32 |
33 | 34 |
35 | 40 |
41 |

42 | You are invited to{" "} 43 | 44 | @{group.unique_name} 45 | 46 |

47 |

{group.name}

48 |
49 | {session == null ? ( 50 | 51 | ) : ( 52 | 53 | )} 54 |
55 |
56 | ); 57 | } 58 | 59 | export async function generateMetadata({ 60 | params, 61 | }: { 62 | params: { invite: string }; 63 | }): Promise { 64 | const info = await getGroupInfo(params.invite); 65 | 66 | if (info != null) { 67 | const title = `Invite to ${info.group.name}`; 68 | const description = `Join ${info.group.name} (@${info.group.unique_name}) on Shark Chat`; 69 | 70 | return { 71 | title, 72 | description, 73 | openGraph: { 74 | title, 75 | description, 76 | }, 77 | twitter: { 78 | title, 79 | description, 80 | }, 81 | }; 82 | } 83 | } 84 | 85 | async function getGroupInfo(query: string): Promise { 86 | const code = decodeURIComponent(query); 87 | 88 | if (code.startsWith("@")) { 89 | const name = code.slice(1); 90 | const groupResult = await db 91 | .select() 92 | .from(groups) 93 | .where(eq(groups.unique_name, name)); 94 | const group = groupResult[0]; 95 | 96 | if (group != null && group.public) { 97 | return { 98 | group, 99 | query: name, 100 | type: "name", 101 | }; 102 | } 103 | } else { 104 | const inviteResult = await db 105 | .select({ group: groups }) 106 | .from(groupInvites) 107 | .where(eq(groupInvites.code, code)) 108 | .innerJoin(groups, eq(groups.id, groupInvites.group_id)); 109 | 110 | if (inviteResult[0] != null) { 111 | return { 112 | group: inviteResult[0].group, 113 | query: code, 114 | type: "code", 115 | }; 116 | } 117 | } 118 | 119 | return null; 120 | } 121 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/layout.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 3 | import { httpBatchLink, TRPCClientError } from "@trpc/client"; 4 | import { useMemo, useState } from "react"; 5 | import { trpc } from "@/utils/trpc"; 6 | import { getBaseUrl } from "@/utils/get-base-url"; 7 | import { showToast } from "@/utils/stores/page"; 8 | import { AblyClientProvider } from "@/utils/ably/client"; 9 | import { PrivateEventManager } from "@/utils/handlers/private"; 10 | import { GroupEventManager } from "@/utils/handlers/group"; 11 | import { MessageEventManager } from "@/utils/handlers/chat"; 12 | import { SessionProvider } from "@/utils/auth"; 13 | 14 | export function Provider({ children }: { children: React.ReactNode }) { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | function ClientProvider({ children }: { children: React.ReactNode }) { 30 | const [queryClient] = useState( 31 | () => 32 | new QueryClient({ 33 | defaultOptions: { 34 | queries: { 35 | retry: false, 36 | retryOnMount: false, 37 | }, 38 | mutations: { 39 | retry: false, 40 | onError(error) { 41 | if (error instanceof TRPCClientError) { 42 | showToast({ 43 | title: "Unknown Error", 44 | description: error.message, 45 | }); 46 | } 47 | }, 48 | }, 49 | }, 50 | }), 51 | ); 52 | const trpcClient = useMemo( 53 | () => 54 | trpc.createClient({ 55 | links: [ 56 | httpBatchLink({ 57 | url: `${getBaseUrl()}/api/trpc`, 58 | }), 59 | ], 60 | }), 61 | [], 62 | ); 63 | 64 | return ( 65 | 66 | {children} 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /apps/web/app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { Provider } from "./layout.client"; 3 | import "cropperjs/dist/cropper.css"; 4 | import CreateGroupModal from "@/components/modal/CreateGroupModal"; 5 | import JoinGroupModal from "@/components/modal/JoinGroupModal"; 6 | 7 | export default function DashboardLayout({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 | 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/app/api/ably/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "next-auth/next"; 2 | import ably from "server/ably"; 3 | import { authOptions } from "server/auth"; 4 | import { NextResponse } from "next/server"; 5 | import { schema } from "server/ably/schema"; 6 | 7 | export async function POST() { 8 | const session = await getServerSession(authOptions); 9 | const clientId = session?.user.id; 10 | 11 | if (clientId == null) { 12 | return NextResponse.json("You must login before connecting to Ably", { 13 | status: 401, 14 | }); 15 | } 16 | 17 | const tokenRequestData = await ably.auth.createTokenRequest({ 18 | clientId: clientId, 19 | capability: { 20 | [schema.private.name(clientId)]: ["subscribe", "publish", "presence"], 21 | ["group:*"]: ["subscribe", "presence"], 22 | ["chat:*"]: ["subscribe", "presence"], 23 | ["chat:*:typing"]: ["subscribe", "publish"], 24 | }, 25 | }); 26 | 27 | return NextResponse.json(tokenRequestData); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from "server/auth"; 2 | import NextAuth from "next-auth"; 3 | 4 | const handler = NextAuth(authOptions); 5 | 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /apps/web/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { appRouter } from "server/routers/_app"; 3 | import { createContext } from "server/context"; 4 | import type { NextRequest } from "next/server"; 5 | 6 | const handler = (req: NextRequest) => 7 | fetchRequestHandler({ 8 | endpoint: "/api/trpc", 9 | req, 10 | router: appRouter, 11 | createContext: () => createContext(req), 12 | }); 13 | 14 | export { handler as GET, handler as POST }; 15 | -------------------------------------------------------------------------------- /apps/web/app/auth/signin/page.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type ClientSafeProvider, signIn } from "next-auth/react"; 4 | import { Button } from "ui/components/button"; 5 | 6 | export function LoginButton({ 7 | provider, 8 | callbackUrl, 9 | }: { 10 | provider: ClientSafeProvider; 11 | callbackUrl?: string; 12 | }) { 13 | return ( 14 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/app/auth/signin/page.tsx: -------------------------------------------------------------------------------- 1 | import { getProviders } from "next-auth/react"; 2 | import { getServerSession } from "next-auth/next"; 3 | import { authOptions } from "server/auth"; 4 | import { redirect } from "next/navigation"; 5 | import { LoginButton } from "./page.client"; 6 | 7 | export default async function SignInPage({ 8 | searchParams, 9 | }: { 10 | searchParams: Record; 11 | }) { 12 | const session = await getServerSession(authOptions); 13 | 14 | if (session) { 15 | redirect(searchParams.callbackUrl ?? "/"); 16 | } 17 | 18 | const providers = await getProviders().then((res) => 19 | Object.values(res ?? {}), 20 | ); 21 | 22 | return ( 23 |
24 |
25 |

Login to Shark Chat

26 |

27 | Login or register an account to start your life on Shark Chat 28 |

29 | {providers?.map((provider) => ( 30 | 35 | ))} 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fuma-nama/shark-chat-js/e647ec70540e06b709556047f979f74c27f4feed/apps/web/app/favicon.ico -------------------------------------------------------------------------------- /apps/web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { ThemeProvider } from "./theme"; 3 | import { Inter } from "next/font/google"; 4 | import "@/styles/globals.css"; 5 | import { getBaseUrl } from "@/utils/get-base-url"; 6 | 7 | export const metadata: Metadata = { 8 | title: "Shark Chat", 9 | description: "An Open-Source Modern Chat App", 10 | twitter: { 11 | card: "summary_large_image", 12 | }, 13 | metadataBase: new URL(getBaseUrl()), 14 | }; 15 | 16 | const inter = Inter({ 17 | subsets: ["latin"], 18 | }); 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: { 23 | children: React.ReactNode; 24 | }) { 25 | return ( 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fuma-nama/shark-chat-js/e647ec70540e06b709556047f979f74c27f4feed/apps/web/app/opengraph-image.png -------------------------------------------------------------------------------- /apps/web/app/theme.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | export { ThemeProvider } from "next-themes"; 3 | -------------------------------------------------------------------------------- /apps/web/components/BannerImage.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { cloudinaryLoader } from "@/utils/cloudinary-loader"; 3 | import { forwardRef, type HTMLAttributes } from "react"; 4 | import { cn } from "ui/utils/cn"; 5 | 6 | interface BannerImageProps extends HTMLAttributes { 7 | url: string | null; 8 | } 9 | export const BannerImage = forwardRef( 10 | ({ url, className, ...props }, ref) => { 11 | if (url) { 12 | return ( 13 |
21 | Banner 29 |
30 | ); 31 | } 32 | 33 | return ( 34 |
42 | ); 43 | }, 44 | ); 45 | 46 | BannerImage.displayName = "BannerImage"; 47 | -------------------------------------------------------------------------------- /apps/web/components/ThemeSwitch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useTheme } from "next-themes"; 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectItem, 7 | SelectProps, 8 | SelectTrigger, 9 | SelectValue, 10 | } from "ui/components/select"; 11 | import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; 12 | import { useMemo } from "react"; 13 | import { useMounted } from "ui/hooks/use-mounted"; 14 | 15 | export type ThemeSwitchProps = Omit & { 16 | id?: string; 17 | }; 18 | 19 | export function ThemeSwitch({ id, ...props }: ThemeSwitchProps) { 20 | const { theme, themes, setTheme } = useTheme(); 21 | const options = useMemo(() => themes.map(getInfo), [themes]); 22 | const mounted = useMounted(); 23 | 24 | if (!mounted) return <>; 25 | 26 | return ( 27 | 42 | ); 43 | } 44 | 45 | function getInfo(theme: string) { 46 | switch (theme) { 47 | case "light": 48 | return { 49 | name: "Light", 50 | icon: , 51 | value: theme, 52 | }; 53 | case "dark": 54 | return { 55 | name: "Dark", 56 | icon: , 57 | value: theme, 58 | }; 59 | case "system": 60 | return { 61 | name: "System", 62 | icon: , 63 | value: theme, 64 | }; 65 | default: 66 | return { name: theme, icon: undefined, value: theme }; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /apps/web/components/chat/AttachmentItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { AttachmentType } from "shared/schema/chat"; 3 | import { useState } from "react"; 4 | import { ImageSkeleton } from "ui/components/image-skeleton"; 5 | import Image from "next/image"; 6 | import { cloudinary_prefix, cloudinaryLoader } from "@/utils/cloudinary-loader"; 7 | import { 8 | Dialog, 9 | DialogClose, 10 | DialogContent, 11 | DialogTrigger, 12 | } from "ui/components/dialog"; 13 | 14 | export function UploadingAttachmentItem({ file }: { file: File }) { 15 | return ( 16 |
17 |

{file.name}

18 |

Uploading...

19 |
20 | ); 21 | } 22 | 23 | export function AttachmentItem({ attachment }: { attachment: AttachmentType }) { 24 | if ( 25 | attachment.type === "image" && 26 | attachment.width != null && 27 | attachment.height != null && 28 | attachment.url.startsWith(cloudinary_prefix) 29 | ) { 30 | return ; 31 | } 32 | 33 | return ( 34 |
35 | 40 | {attachment.name} 41 | 42 |

{attachment.bytes} Bytes

43 |
44 | ); 45 | } 46 | 47 | function AttachmentImage({ attachment }: { attachment: AttachmentType }) { 48 | const [isLoaded, setIsLoaded] = useState(false); 49 | const url = decodeURIComponent(attachment.url); 50 | 51 | return ( 52 | 53 | 60 | 61 | {attachment.name} setIsLoaded(true)} 69 | /> 70 | 71 | 72 | 73 | 74 | 75 | image 83 | 84 | 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /apps/web/components/chat/ChatView.tsx: -------------------------------------------------------------------------------- 1 | import { usePageStore } from "@/utils/stores/page"; 2 | import dynamic from "next/dynamic"; 3 | import { createContext, ReactNode, useContext } from "react"; 4 | 5 | const UserProfileModal = dynamic(() => import("../modal/UserProfileModal")); 6 | 7 | export interface ChatViewContext { 8 | deleteMessage: boolean; 9 | } 10 | 11 | export const ChatContext = createContext( 12 | undefined, 13 | ); 14 | 15 | export function ChatViewport({ 16 | children, 17 | ...props 18 | }: ChatViewContext & { children: ReactNode }) { 19 | const [modal, setModal] = usePageStore((s) => [s.modal, s.setModal]); 20 | 21 | return ( 22 |
26 | setModal(undefined)} 30 | /> 31 |
32 | {children} 33 |
34 |
35 | ); 36 | } 37 | 38 | export function useViewContext() { 39 | return useContext(ChatContext)!; 40 | } 41 | 42 | export function getViewportScroll(): HTMLDivElement | null { 43 | return document.getElementById("scroll") as HTMLDivElement; 44 | } 45 | 46 | export function getViewportInner(): HTMLDivElement | null { 47 | return document.getElementById("scroll-inner") as HTMLDivElement; 48 | } 49 | -------------------------------------------------------------------------------- /apps/web/components/chat/TypingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { Avatar } from "ui/components/avatar"; 3 | import { type TypingUser, useMessageStore } from "@/utils/stores/chat"; 4 | import { useSession } from "@/utils/auth"; 5 | 6 | function useTypingStatus(channelId: string): TypingUser[] { 7 | const [typing, setTyping] = useState([]); 8 | const data = useMessageStore((s) => s.typing.get(channelId)); 9 | const update = useRef<() => void>(); 10 | const { data: session } = useSession(); 11 | 12 | update.current = () => { 13 | if (!session) return; 14 | 15 | setTyping( 16 | data?.filter( 17 | (item) => 18 | Date.now() - item.timestamp <= 5000 && 19 | item.user.id !== session.user.id, 20 | ) ?? [], 21 | ); 22 | }; 23 | 24 | useEffect(() => { 25 | const timer = setInterval(() => update.current?.(), 1000); 26 | 27 | return () => { 28 | clearInterval(timer); 29 | }; 30 | }, [channelId]); 31 | 32 | useEffect(() => { 33 | update.current?.(); 34 | }, [data, session]); 35 | 36 | return typing; 37 | } 38 | 39 | export function TypingIndicator({ channelId }: { channelId: string }) { 40 | const typing = useTypingStatus(channelId); 41 | if (typing.length === 0) return <>; 42 | 43 | return ( 44 |
45 | {typing.map((data) => ( 46 | 52 | ))} 53 |

is typing...

54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /apps/web/components/chat/message/atom.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, ReactNode, useMemo } from "react"; 2 | import { Avatar } from "ui/components/avatar"; 3 | import * as ContextMenu from "ui/components/context-menu"; 4 | import { getTimeString } from "ui/utils/time"; 5 | import { MessageType } from "@/utils/types"; 6 | import { usePageStore } from "@/utils/stores/page"; 7 | import { DropdownMenu, DropdownMenuTrigger } from "ui/components/dropdown"; 8 | import { MoreHorizontalIcon } from "lucide-react"; 9 | import { button } from "ui/components/button"; 10 | import { tv } from "tailwind-variants"; 11 | import { render } from "@/components/chat/message/markdown"; 12 | import { useMessageStore } from "@/utils/stores/chat"; 13 | import { cn } from "ui/utils/cn"; 14 | import type { UserInfo } from "shared/schema/chat"; 15 | 16 | interface ContentProps extends React.HTMLAttributes { 17 | user: MessageType["author"]; 18 | chainStart: boolean; 19 | timestamp: string | Date | number; 20 | chainEnd: boolean; 21 | } 22 | 23 | const contentVariants = tv({ 24 | base: "relative group px-2 mx-2 rounded-xl text-[15px] data-[state=open]:bg-accent/50 hover:bg-accent/50 md:mx-4", 25 | variants: { 26 | chain: { 27 | head: "flex flex-row items-start gap-2 pt-2", 28 | body: "flex flex-col gap-2 py-0.5", 29 | }, 30 | }, 31 | }); 32 | 33 | const defaultUser: UserInfo = { 34 | id: "", 35 | image: null, 36 | name: "Deleted User", 37 | }; 38 | 39 | export const Content = forwardRef( 40 | ({ user, timestamp, className, chainStart, chainEnd, ...props }, ref) => { 41 | const author = user ?? defaultUser; 42 | const status = useMessageStore((s) => s.status[author.id]) ?? { 43 | type: "offline", 44 | }; 45 | const date = new Date(timestamp); 46 | 47 | const onOpenProfile = () => { 48 | usePageStore.getState().setModal({ type: "user", user_id: author.id }); 49 | }; 50 | 51 | if (!chainStart) { 52 | return ( 53 |
61 |
{props.children}
62 |
63 | ); 64 | } 65 | 66 | return ( 67 |
75 |
76 | 81 |
88 |
89 |
90 |
91 |

95 | {author.name} 96 |

97 | 98 |

99 | {getTimeString(date)} 100 |

101 |
102 | {props.children} 103 |
104 |
105 | ); 106 | }, 107 | ); 108 | 109 | Content.displayName = "MessageContent"; 110 | 111 | export function Menu() { 112 | return ( 113 | 121 | 122 | 123 | ); 124 | } 125 | 126 | type RootProps = { 127 | children: ReactNode; 128 | }; 129 | 130 | export function Root({ children }: RootProps) { 131 | return ( 132 | 133 | {children} 134 | 135 | ); 136 | } 137 | 138 | export function Text({ children }: { children: string }) { 139 | const output = useMemo(() => render(children), [children]); 140 | 141 | return ( 142 |
143 | {output} 144 |
145 | ); 146 | } 147 | -------------------------------------------------------------------------------- /apps/web/components/chat/message/edit.tsx: -------------------------------------------------------------------------------- 1 | import { trpc } from "@/utils/trpc"; 2 | import { MessageType } from "@/utils/types"; 3 | import { useEffect } from "react"; 4 | import { Controller, useForm } from "react-hook-form"; 5 | import { Button } from "ui/components/button"; 6 | import { textArea } from "ui/components/textarea"; 7 | import { useMessageStore } from "@/utils/stores/chat"; 8 | 9 | type EditProps = { 10 | message: MessageType; 11 | }; 12 | 13 | export default function Edit({ message }: EditProps) { 14 | const editMutation = trpc.chat.update.useMutation({ 15 | onSuccess: () => { 16 | onCancel(); 17 | }, 18 | }); 19 | 20 | const { control, handleSubmit, setFocus } = useForm<{ content: string }>({ 21 | defaultValues: { 22 | content: message.content, 23 | }, 24 | }); 25 | 26 | const onSave = handleSubmit((v) => { 27 | editMutation.mutate({ 28 | channelId: message.channel_id, 29 | messageId: message.id, 30 | content: v.content, 31 | }); 32 | }); 33 | 34 | const onCancel = () => { 35 | useMessageStore.getState().setEditing(message.channel_id); 36 | }; 37 | 38 | useEffect(() => { 39 | setFocus("content", { shouldSelect: true }); 40 | }, [setFocus]); 41 | 42 | return ( 43 |
44 | ( 48 |