Skip to content

Commit 150b572

Browse files
committed
Add chatroom features
1 parent 5b094df commit 150b572

File tree

6 files changed

+216
-2
lines changed

6 files changed

+216
-2
lines changed

app/channels/chat_channel.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,26 @@
11
class ChatChannel < ApplicationCable::Channel
2+
def subscribed
3+
reject unless current_user
4+
stream_from "chat"
5+
end
6+
7+
def unsubscribed
8+
# Any cleanup needed when channel is unsubscribed
9+
end
10+
11+
def speak(data)
12+
return unless current_user
13+
14+
message = ChatMessage.create!(
15+
body: data["body"],
16+
user: current_user
17+
)
18+
19+
ActionCable.server.broadcast("chat", {
20+
id: message.id,
21+
body: message.body,
22+
user: { username: message.user.username },
23+
created_at: message.created_at
24+
})
25+
end
226
end

app/controllers/application_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
class ApplicationController < ActionController::Base
22
include Pagy::Method
3+
include ChatMessagesShareable
34

45
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
56
allow_browser versions: :modern
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module ChatMessagesShareable
2+
extend ActiveSupport::Concern
3+
4+
included do
5+
inertia_share do
6+
collection = ChatMessage.includes(:user).order(created_at: :desc)
7+
8+
pagy, records = pagy(collection)
9+
10+
{
11+
chat_messages: InertiaRails.scroll(pagy) do
12+
records.as_json(include: { user: { only: :username } })
13+
end
14+
}
15+
end
16+
end
17+
end

app/frontend/components/Chat.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {ChatBubbleLeftRightIcon} from "@heroicons/react/24/outline";
2+
import ChatDrawer from "@/components/ChatDrawer";
3+
import {useCable} from "@/hooks/use-cable";
4+
import {ChatMessage, User} from "@/types";
5+
import {router, usePage} from "@inertiajs/react";
6+
import {useState} from "react";
7+
8+
9+
export default function Chat() {
10+
const { props: { current_user: currentUser } } = usePage()
11+
const { username } = currentUser || {} as User
12+
13+
const [chatDrawerOpen, setChatDrawerOpen] = useState(false)
14+
const { perform } = useCable("ChatChannel", {enabled: !!username}, (chatMessage: ChatMessage) => {
15+
router.prependToProp('chat_messages', chatMessage)
16+
})
17+
18+
return (<>
19+
<button
20+
onClick={() => setChatDrawerOpen(true)}
21+
className="fixed right-0 top-1/2 -translate-y-1/2 z-40 bg-sky-600 text-white px-3 py-8 rounded-l-lg shadow-lg hover:bg-sky-700 transition-colors duration-200 flex items-center justify-center cursor-pointer"
22+
aria-label="Open chat"
23+
>
24+
<ChatBubbleLeftRightIcon className="h-6 w-6"/>
25+
</button>
26+
<ChatDrawer
27+
open={chatDrawerOpen}
28+
onClose={() => setChatDrawerOpen(false)}
29+
currentUser={currentUser}
30+
onSend={(body: string) => perform('speak', {body})}
31+
/>
32+
</>)
33+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { useState, useEffect, useRef } from 'react'
2+
import { XMarkIcon } from '@heroicons/react/24/outline'
3+
import { InfiniteScroll } from '@inertiajs/react'
4+
import { usePage } from '@inertiajs/react'
5+
import Drawer from './Drawer'
6+
import { ChatMessage, User } from '../types'
7+
8+
interface ChatDrawerProps {
9+
open: boolean
10+
onClose: () => void
11+
currentUser: User
12+
onSend: (message: string) => void
13+
}
14+
15+
export default function ChatDrawer({ open, onClose, onSend, currentUser }: ChatDrawerProps) {
16+
const { props } = usePage<{ chat_messages: ChatMessage[] }>()
17+
const chatMessages = props.chat_messages || []
18+
19+
const [messageText, setMessageText] = useState('')
20+
21+
const isCurrentUser = (username: string) => username === currentUser.username
22+
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
23+
24+
25+
const AUTO_SCROLL_BUFFER = 200
26+
useEffect(() => {
27+
if (scrollContainerRef.current && chatMessages.length > 0) {
28+
const container = scrollContainerRef.current
29+
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < AUTO_SCROLL_BUFFER
30+
31+
if (isNearBottom) {
32+
container.scrollTo({
33+
top: container.scrollHeight,
34+
behavior: 'smooth'
35+
})
36+
}
37+
}
38+
}, [chatMessages.length, chatMessages])
39+
40+
const handleSendMessage = () => {
41+
if (!messageText.trim()) return
42+
43+
onSend(messageText)
44+
setMessageText('')
45+
}
46+
47+
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
48+
if (e.key === 'Enter' && !e.shiftKey) {
49+
e.preventDefault()
50+
handleSendMessage()
51+
}
52+
}
53+
54+
return (
55+
<Drawer open={open} onClose={onClose}>
56+
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
57+
<h2 className="text-lg font-semibold text-gray-900">Chat</h2>
58+
<button
59+
onClick={onClose}
60+
className="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
61+
>
62+
<XMarkIcon className="h-5 w-5" />
63+
</button>
64+
</div>
65+
66+
<div className="overflow-y-scroll p-4 [overflow-anchor:auto]" ref={scrollContainerRef}>
67+
<InfiniteScroll
68+
data="chat_messages"
69+
reverse
70+
onlyNext
71+
preserveUrl
72+
autoScroll={true}
73+
buffer={200}
74+
className="flex flex-col-reverse"
75+
>
76+
{chatMessages.map((message) => {
77+
const isCurrentUserMessage = isCurrentUser(message.user.username)
78+
return (
79+
<div
80+
key={message.id}
81+
className={`flex gap-3 mb-4 ${isCurrentUserMessage ? 'flex-row-reverse' : 'flex-row'}`}
82+
>
83+
{!isCurrentUserMessage && (
84+
<div className="flex-shrink-0">
85+
<div className="w-10 h-10 rounded-full bg-sky-600 flex items-center justify-center text-white text-sm font-medium">
86+
{message.user.username.charAt(0).toUpperCase()}
87+
</div>
88+
</div>
89+
)}
90+
91+
<div className={`flex flex-col ${isCurrentUserMessage ? 'items-end' : 'items-start'} max-w-[90%]`}>
92+
<div
93+
className={`px-4 py-2 rounded-lg ${isCurrentUserMessage
94+
? 'bg-sky-600 text-white'
95+
: 'bg-gray-200 text-gray-900'
96+
}`}
97+
>
98+
<p className="text-sm">{message.body}</p>
99+
</div>
100+
<span className="mt-1 text-xs text-gray-500">
101+
{new Date(message.created_at).toLocaleString([], {
102+
month: 'short',
103+
day: 'numeric',
104+
hour: '2-digit',
105+
minute: '2-digit'
106+
})}
107+
</span>
108+
</div>
109+
</div>
110+
)
111+
})}
112+
</InfiniteScroll>
113+
</div>
114+
115+
<div className="border-t border-gray-200 p-4">
116+
<div className="flex gap-2">
117+
<input
118+
type="text"
119+
value={messageText}
120+
onChange={(e) => setMessageText(e.target.value)}
121+
onKeyUp={handleKeyPress}
122+
placeholder="Type a message..."
123+
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-sky-500 focus:border-sky-500"
124+
/>
125+
<button
126+
type="button"
127+
onClick={handleSendMessage}
128+
className="px-4 py-2 bg-sky-600 text-white rounded-md text-sm font-medium hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
129+
>
130+
Send
131+
</button>
132+
</div>
133+
</div>
134+
</Drawer>
135+
)
136+
}

app/frontend/layouts/AppLayout.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
44
import {
55
Bars3Icon,
66
MagnifyingGlassIcon,
7-
ChevronDownIcon
7+
ChevronDownIcon,
88
} from '@heroicons/react/24/outline'
99
import { User } from '@/types'
1010
import Sidebar from "@/components/Sidebar";
11+
import Chat from "@/components/Chat";
1112

1213
interface AppLayoutProps {
1314
children: ReactNode
@@ -21,7 +22,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
2122

2223
return (
2324
<div className="flex h-screen bg-gray-50">
24-
<Sidebar isOpen={sidebarOpen} />
25+
<Sidebar isOpen={sidebarOpen}/>
2526

2627
<div className="flex-1 flex flex-col overflow-hidden">
2728
<header className="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6">
@@ -95,6 +96,8 @@ export default function AppLayout({ children }: AppLayoutProps) {
9596
{children}
9697
</main>
9798
</div>
99+
100+
{currentUser && (<Chat />)}
98101
</div>
99102
)
100103
}

0 commit comments

Comments
 (0)