├── src
├── vite-env.d.ts
├── index.css
├── lib
│ └── react-query.ts
├── main.tsx
├── http
│ ├── create-message-reaction.ts
│ ├── remove-message-reaction.ts
│ ├── create-room.ts
│ ├── create-message.ts
│ └── get-room-messages.ts
├── app.tsx
├── components
│ ├── messages.tsx
│ ├── create-message-form.tsx
│ └── message.tsx
├── pages
│ ├── room.tsx
│ └── create-room.tsx
├── assets
│ └── ama-logo.svg
└── hooks
│ └── use-messages-web-sockets.ts
├── public
└── icon.png
├── postcss.config.js
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
├── .gitignore
├── tsconfig.node.json
├── index.html
├── .eslintrc.cjs
├── tsconfig.app.json
└── package.json
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rocketseat-education/semana-tech-01-go-react-web/HEAD/public/icon.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/react-query.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from "@tanstack/react-query";
2 |
3 | export const queryClient = new QueryClient()
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | './src/**/*.tsx',
5 | './index.html'
6 | ],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import { App } from './app'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/src/http/create-message-reaction.ts:
--------------------------------------------------------------------------------
1 | interface CreateMessageReactionRequest {
2 | roomId: string
3 | messageId: string
4 | }
5 |
6 | export async function createMessageReaction({ messageId, roomId }: CreateMessageReactionRequest) {
7 | await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms/${roomId}/messages/${messageId}/react`, {
8 | method: 'PATCH',
9 | })
10 | }
--------------------------------------------------------------------------------
/src/http/remove-message-reaction.ts:
--------------------------------------------------------------------------------
1 | interface RemoveMessageReactionRequest {
2 | roomId: string
3 | messageId: string
4 | }
5 |
6 | export async function removeMessageReaction({ messageId, roomId }: RemoveMessageReactionRequest) {
7 | await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms/${roomId}/messages/${messageId}/react`, {
8 | method: 'DELETE',
9 | })
10 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .env
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "skipLibCheck": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "noEmit": true
11 | },
12 | "include": ["vite.config.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/src/http/create-room.ts:
--------------------------------------------------------------------------------
1 | interface CreateRoomRequest {
2 | theme: string
3 | }
4 |
5 | export async function createRoom({ theme }: CreateRoomRequest) {
6 | const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms`, {
7 | method: 'POST',
8 | body: JSON.stringify({
9 | theme,
10 | })
11 | })
12 |
13 | const data: { id: string } = await response.json()
14 |
15 | return { roomId: data.id }
16 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | AMA | Ask my anything
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/http/create-message.ts:
--------------------------------------------------------------------------------
1 | interface CreateMessageRequest {
2 | roomId: string
3 | message: string
4 | }
5 |
6 | export async function createMessage({ roomId, message }: CreateMessageRequest) {
7 | const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms/${roomId}/messages`, {
8 | method: 'POST',
9 | body: JSON.stringify({
10 | message,
11 | })
12 | })
13 |
14 | const data: { id: string } = await response.json()
15 |
16 | return { messageId: data.id }
17 | }
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from 'sonner'
2 | import { createBrowserRouter, RouterProvider } from 'react-router-dom'
3 | import { CreateRoom } from './pages/create-room'
4 | import { Room } from './pages/room'
5 | import { QueryClientProvider } from '@tanstack/react-query'
6 | import { queryClient } from './lib/react-query'
7 |
8 | const router = createBrowserRouter([
9 | {
10 | path: '/',
11 | element:
12 | },
13 | {
14 | path: '/room/:roomId',
15 | element:
16 | }
17 | ])
18 |
19 | export function App() {
20 | return (
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
28 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "moduleDetection": "force",
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 |
20 | /* Linting */
21 | "strict": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noFallthroughCasesInSwitch": true
25 | },
26 | "include": ["src"]
27 | }
28 |
--------------------------------------------------------------------------------
/src/http/get-room-messages.ts:
--------------------------------------------------------------------------------
1 | interface GetRoomMessagesRequest {
2 | roomId: string
3 | }
4 |
5 | export interface GetRoomMessagesResponse {
6 | messages: {
7 | id: string;
8 | text: string;
9 | amountOfReactions: number;
10 | answered: boolean;
11 | }[]
12 | }
13 |
14 | export async function getRoomMessages({ roomId }: GetRoomMessagesRequest): Promise {
15 | const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms/${roomId}/messages`)
16 |
17 | const data: Array<{
18 | id: string
19 | room_id: string
20 | message: string
21 | reaction_count: number
22 | answered: boolean
23 | }> = await response.json()
24 |
25 | return {
26 | messages: data.map(item => {
27 | return {
28 | id: item.id,
29 | text: item.message,
30 | amountOfReactions: item.reaction_count,
31 | answered: item.answered,
32 | }
33 | })
34 | }
35 | }
--------------------------------------------------------------------------------
/src/components/messages.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom";
2 | import { Message } from "./message";
3 | import { getRoomMessages } from "../http/get-room-messages";
4 | import { useSuspenseQuery } from "@tanstack/react-query";
5 | import { useMessagesWebSockets } from "../hooks/use-messages-web-sockets";
6 |
7 | export function Messages() {
8 | const { roomId } = useParams()
9 |
10 | if (!roomId) {
11 | throw new Error('Messages components must be used within room page')
12 | }
13 |
14 | const { data } = useSuspenseQuery({
15 | queryKey: ['messages', roomId],
16 | queryFn: () => getRoomMessages({ roomId }),
17 | })
18 |
19 | useMessagesWebSockets({ roomId })
20 |
21 | const sortedMessages = data.messages.sort((a, b) => {
22 | return b.amountOfReactions - a.amountOfReactions
23 | })
24 |
25 | return (
26 |
27 | {sortedMessages.map(message => {
28 | return (
29 |
36 | )
37 | })}
38 |
39 | )
40 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@tanstack/react-query": "5.51.17",
14 | "lucide-react": "0.417.0",
15 | "react": "19.0.0-rc-3208e73e-20240730",
16 | "react-dom": "19.0.0-rc-3208e73e-20240730",
17 | "react-router-dom": "6.25.1",
18 | "sonner": "1.5.0"
19 | },
20 | "devDependencies": {
21 | "@types/react": "npm:types-react@rc",
22 | "@types/react-dom": "npm:types-react-dom@rc",
23 | "@typescript-eslint/eslint-plugin": "^7.15.0",
24 | "@typescript-eslint/parser": "^7.15.0",
25 | "@vitejs/plugin-react": "^4.3.1",
26 | "autoprefixer": "10.4.19",
27 | "eslint": "^8.57.0",
28 | "eslint-plugin-react-hooks": "^4.6.2",
29 | "eslint-plugin-react-refresh": "^0.4.7",
30 | "postcss": "8.4.40",
31 | "tailwindcss": "3.4.7",
32 | "typescript": "^5.2.2",
33 | "vite": "^5.3.4"
34 | },
35 | "overrides": {
36 | "@types/react": "npm:types-react@rc",
37 | "@types/react-dom": "npm:types-react-dom@rc"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/create-message-form.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowRight } from "lucide-react";
2 | import { useParams } from "react-router-dom";
3 | import { createMessage } from "../http/create-message";
4 | import { toast } from "sonner";
5 |
6 | export function CreateMessageForm() {
7 | const { roomId } = useParams()
8 |
9 | if (!roomId) {
10 | throw new Error('Messages components must be used within room page')
11 | }
12 |
13 | async function createMessageAction(data: FormData) {
14 | const message = data.get('message')?.toString()
15 |
16 | if (!message || !roomId) {
17 | return
18 | }
19 |
20 | try {
21 | await createMessage({ message, roomId })
22 | } catch {
23 | toast.error('Falha ao enviar pergunta, tente novamente!')
24 | }
25 | }
26 |
27 | return (
28 |
49 | )
50 | }
--------------------------------------------------------------------------------
/src/pages/room.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom"
2 | import { Share2 } from "lucide-react"
3 | import { toast } from "sonner"
4 |
5 | import amaLogo from '../assets/ama-logo.svg'
6 | import { Messages } from "../components/messages"
7 | import { Suspense } from "react"
8 | import { CreateMessageForm } from "../components/create-message-form"
9 |
10 | export function Room() {
11 | const { roomId } = useParams()
12 |
13 | function handleShareRoom() {
14 | const url = window.location.href.toString()
15 |
16 | if (navigator.share !== undefined && navigator.canShare()) {
17 | navigator.share({ url })
18 | } else {
19 | navigator.clipboard.writeText(url)
20 |
21 | toast.info('O link da sala foi copiado para área de transferência!')
22 | }
23 | }
24 |
25 | return (
26 |
27 |
28 |

29 |
30 |
31 | Código da sala: {roomId}
32 |
33 |
34 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
Carregando...}>
49 |
50 |
51 |
52 | )
53 | }
--------------------------------------------------------------------------------
/src/pages/create-room.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowRight } from 'lucide-react'
2 | import { toast } from 'sonner'
3 | import { useNavigate } from 'react-router-dom'
4 |
5 | import amaLogo from '../assets/ama-logo.svg'
6 | import { createRoom } from '../http/create-room'
7 |
8 | export function CreateRoom() {
9 | const navigate = useNavigate()
10 |
11 | async function handleCreateRoom(data: FormData) {
12 | const theme = data.get('theme')?.toString()
13 |
14 | if (!theme) {
15 | return
16 | }
17 |
18 | try {
19 | const { roomId } = await createRoom({ theme })
20 |
21 | navigate(`/room/${roomId}`)
22 | } catch {
23 | toast.error('Falha ao criar sala!')
24 | }
25 | }
26 |
27 | return (
28 |
29 |
30 |

31 |
32 |
33 | Crie uma sala pública de AMA (Ask me anything) e priorize as perguntas mais importantes para a comunidade.
34 |
35 |
36 |
57 |
58 |
59 | )
60 | }
--------------------------------------------------------------------------------
/src/components/message.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUp } from "lucide-react";
2 | import { useState } from "react";
3 | import { useParams } from "react-router-dom";
4 | import { createMessageReaction } from "../http/create-message-reaction";
5 | import { toast } from "sonner";
6 | import { removeMessageReaction } from "../http/remove-message-reaction";
7 |
8 | interface MessageProps {
9 | id: string
10 | text: string
11 | amountOfReactions: number
12 | answered?: boolean
13 | }
14 |
15 | export function Message({
16 | id: messageId,
17 | text,
18 | amountOfReactions,
19 | answered = false,
20 | }: MessageProps) {
21 | const { roomId } = useParams()
22 | const [hasReacted, setHasReacted] = useState(false)
23 |
24 | if (!roomId) {
25 | throw new Error('Messages components must be used within room page')
26 | }
27 |
28 | async function createMessageReactionAction() {
29 | if (!roomId) {
30 | return
31 | }
32 |
33 | try {
34 | await createMessageReaction({ messageId, roomId })
35 | } catch {
36 | toast.error('Falha ao reagir mensagem, tente novamente!')
37 | }
38 |
39 | setHasReacted(true)
40 | }
41 |
42 | async function removeMessageReactionAction() {
43 | if (!roomId) {
44 | return
45 | }
46 |
47 | try {
48 | await removeMessageReaction({ messageId, roomId })
49 | } catch {
50 | toast.error('Falha ao remover reação, tente novamente!')
51 | }
52 |
53 | setHasReacted(false)
54 | }
55 |
56 | return (
57 |
58 | {text}
59 |
60 | {hasReacted ? (
61 |
69 | ) : (
70 |
78 | )}
79 |
80 | )
81 | }
--------------------------------------------------------------------------------
/src/assets/ama-logo.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/src/hooks/use-messages-web-sockets.ts:
--------------------------------------------------------------------------------
1 | import { useQueryClient } from "@tanstack/react-query"
2 | import { useEffect } from "react"
3 | import type { GetRoomMessagesResponse } from "../http/get-room-messages"
4 |
5 | interface useMessagesWebSocketsParams {
6 | roomId: string
7 | }
8 |
9 | type WebhookMessage =
10 | | { kind: "message_created"; value: { id: string, message: string } }
11 | | { kind: "message_answered"; value: { id: string } }
12 | | { kind: "message_reaction_increased"; value: { id: string; count: number } }
13 | | { kind: "message_reaction_decreased"; value: { id: string; count: number } };
14 |
15 | export function useMessagesWebSockets({
16 | roomId,
17 | }: useMessagesWebSocketsParams) {
18 | const queryClient = useQueryClient()
19 |
20 | useEffect(() => {
21 | const ws = new WebSocket(`ws://localhost:8080/subscribe/${roomId}`)
22 |
23 | ws.onopen = () => {
24 | console.log('Websocket connected!')
25 | }
26 |
27 | ws.onclose = () => {
28 | console.log('Websocket connection closed!')
29 | }
30 |
31 | ws.onmessage = (event) => {
32 | const data: WebhookMessage = JSON.parse(event.data)
33 |
34 | switch(data.kind) {
35 | case 'message_created':
36 | queryClient.setQueryData(['messages', roomId], state => {
37 | return {
38 | messages: [
39 | ...(state?.messages ?? []),
40 | {
41 | id: data.value.id,
42 | text: data.value.message,
43 | amountOfReactions: 0,
44 | answered: false,
45 | }
46 | ],
47 | }
48 | })
49 |
50 | break;
51 | case 'message_answered':
52 | queryClient.setQueryData(['messages', roomId], state => {
53 | if (!state) {
54 | return undefined
55 | }
56 |
57 | return {
58 | messages: state.messages.map(item => {
59 | if (item.id === data.value.id) {
60 | return { ...item, answered: true }
61 | }
62 |
63 | return item
64 | }),
65 | }
66 | })
67 |
68 | break;
69 | case 'message_reaction_increased':
70 | case 'message_reaction_decreased':
71 | queryClient.setQueryData(['messages', roomId], state => {
72 | if (!state) {
73 | return undefined
74 | }
75 |
76 | return {
77 | messages: state.messages.map(item => {
78 | if (item.id === data.value.id) {
79 | return { ...item, amountOfReactions: data.value.count }
80 | }
81 |
82 | return item
83 | }),
84 | }
85 | })
86 |
87 | break;
88 | }
89 | }
90 |
91 | return () => {
92 | ws.close()
93 | }
94 | }, [roomId, queryClient])
95 | }
--------------------------------------------------------------------------------