Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/channels/chat_channel.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,26 @@
class ChatChannel < ApplicationCable::Channel
def subscribed
reject unless current_user
stream_from "chat"
end

def unsubscribed
# Any cleanup needed when channel is unsubscribed
end

def speak(data)
return unless current_user

message = ChatMessage.create!(
body: data["body"],
user: current_user
)

ActionCable.server.broadcast("chat", {
id: message.id,
body: message.body,
user: { username: message.user.username },
created_at: message.created_at
})
end
end
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class ApplicationController < ActionController::Base
include Pagy::Method
include ChatMessagesShareable

# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
Expand Down
17 changes: 17 additions & 0 deletions app/controllers/concerns/chat_messages_shareable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module ChatMessagesShareable
extend ActiveSupport::Concern

included do
inertia_share do
collection = ChatMessage.includes(:user).order(created_at: :desc)

pagy, records = pagy(collection)

{
chat_messages: InertiaRails.scroll(pagy) do
records.as_json(include: { user: { only: :username } })
end
}
end
end
end
33 changes: 33 additions & 0 deletions app/frontend/components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {ChatBubbleLeftRightIcon} from "@heroicons/react/24/outline";
import ChatDrawer from "@/components/ChatDrawer";
import {useCable} from "@/hooks/use-cable";
import {ChatMessage, User} from "@/types";
import {router, usePage} from "@inertiajs/react";
import {useState} from "react";


export default function Chat() {
const { props: { current_user: currentUser } } = usePage()
const { username } = currentUser || {} as User

const [chatDrawerOpen, setChatDrawerOpen] = useState(false)
const { perform } = useCable("ChatChannel", {enabled: !!username}, (chatMessage: ChatMessage) => {
router.prependToProp('chat_messages', chatMessage)
})

return (<>
<button
onClick={() => setChatDrawerOpen(true)}
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"
aria-label="Open chat"
>
<ChatBubbleLeftRightIcon className="h-6 w-6"/>
</button>
<ChatDrawer
open={chatDrawerOpen}
onClose={() => setChatDrawerOpen(false)}
currentUser={currentUser}
onSend={(body: string) => perform('speak', {body})}
/>
</>)
}
136 changes: 136 additions & 0 deletions app/frontend/components/ChatDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useState, useEffect, useRef } from 'react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { InfiniteScroll } from '@inertiajs/react'
import { usePage } from '@inertiajs/react'
import Drawer from './Drawer'
import { ChatMessage, User } from '../types'

interface ChatDrawerProps {
open: boolean
onClose: () => void
currentUser: User
onSend: (message: string) => void
}

export default function ChatDrawer({ open, onClose, onSend, currentUser }: ChatDrawerProps) {
const { props } = usePage<{ chat_messages: ChatMessage[] }>()
const chatMessages = props.chat_messages || []

const [messageText, setMessageText] = useState('')

const isCurrentUser = (username: string) => username === currentUser.username
const scrollContainerRef = useRef<HTMLDivElement | null>(null)


const AUTO_SCROLL_BUFFER = 200
useEffect(() => {
if (scrollContainerRef.current && chatMessages.length > 0) {
const container = scrollContainerRef.current
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < AUTO_SCROLL_BUFFER

if (isNearBottom) {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
})
}
}
}, [chatMessages.length, chatMessages])

const handleSendMessage = () => {
if (!messageText.trim()) return

onSend(messageText)
setMessageText('')
}

const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}

return (
<Drawer open={open} onClose={onClose}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Chat</h2>
<button
onClick={onClose}
className="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>

<div className="overflow-y-scroll p-4 [overflow-anchor:auto]" ref={scrollContainerRef}>
<InfiniteScroll
data="chat_messages"
reverse
onlyNext
preserveUrl
autoScroll={true}
buffer={200}
className="flex flex-col-reverse"
>
{chatMessages.map((message) => {
const isCurrentUserMessage = isCurrentUser(message.user.username)
return (
<div
key={message.id}
className={`flex gap-3 mb-4 ${isCurrentUserMessage ? 'flex-row-reverse' : 'flex-row'}`}
>
{!isCurrentUserMessage && (
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-sky-600 flex items-center justify-center text-white text-sm font-medium">
{message.user.username.charAt(0).toUpperCase()}
</div>
</div>
)}

<div className={`flex flex-col ${isCurrentUserMessage ? 'items-end' : 'items-start'} max-w-[90%]`}>
<div
className={`px-4 py-2 rounded-lg ${isCurrentUserMessage
? 'bg-sky-600 text-white'
: 'bg-gray-200 text-gray-900'
}`}
>
<p className="text-sm">{message.body}</p>
</div>
<span className="mt-1 text-xs text-gray-500">
{new Date(message.created_at).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
</div>
)
})}
</InfiniteScroll>
</div>

<div className="border-t border-gray-200 p-4">
<div className="flex gap-2">
<input
type="text"
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyUp={handleKeyPress}
placeholder="Type a message..."
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"
/>
<button
type="button"
onClick={handleSendMessage}
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"
>
Send
</button>
</div>
</div>
</Drawer>
)
}
7 changes: 5 additions & 2 deletions app/frontend/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import {
Bars3Icon,
MagnifyingGlassIcon,
ChevronDownIcon
ChevronDownIcon,
} from '@heroicons/react/24/outline'
import { User } from '@/types'
import Sidebar from "@/components/Sidebar";
import Chat from "@/components/Chat";

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

return (
<div className="flex h-screen bg-gray-50">
<Sidebar isOpen={sidebarOpen} />
<Sidebar isOpen={sidebarOpen}/>

<div className="flex-1 flex flex-col overflow-hidden">
<header className="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6">
Expand Down Expand Up @@ -95,6 +96,8 @@ export default function AppLayout({ children }: AppLayoutProps) {
{children}
</main>
</div>

{currentUser && (<Chat />)}
</div>
)
}