Skip to content

Commit

Permalink
fix: support chat
Browse files Browse the repository at this point in the history
Now I let user to send messages
Also I created input as in openAI
  • Loading branch information
nicitaacom committed Jul 13, 2024
1 parent 6e4cbeb commit 12d083c
Show file tree
Hide file tree
Showing 17 changed files with 4,334 additions and 5,117 deletions.
77 changes: 77 additions & 0 deletions app/(site)/functions/sendChatMessageFn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import axios, { AxiosError } from "axios"
import moment from "moment-timezone"
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"

import { IMessage } from "@/interfaces/support/IMessage"
import { TAPITelegram } from "@/api/telegram/route"
import { TAPITicketsOpen } from "@/api/tickets/open/route"
import { TAPIMessageSend } from "@/api/message/send/route"
import { useMessagesStore } from "@/store/ui/useMessagesStore"
import useUserStore from "@/store/user/userStore"
import { getUserId } from "@/utils/getUserId"

export async function sendChatMessageFn(inputValue: string, sender_id: string, router: AppRouterInstance) {
// don't allow to send empty message
if (inputValue.length === 0) {
return null
}

const { messages, setMessages, ticketId: ticketIdState, setTicketId } = useMessagesStore.getState()

const isFirstMessage = messages.length === 0
const ticketId = ticketIdState || getUserId()

const message: IMessage = {
id: crypto.randomUUID(), // to don't wait response from DB about generated id
created_at: moment().tz("Europe/Berlin").format(),
seen: false,
body: inputValue,
sender_id: sender_id,
sender_username: sender_id,
ticket_id: ticketId,
}

setMessages([...messages, message]) // optimistically set state

if (isFirstMessage) {
setTicketId(ticketId)
console.log(38, "messages - ", messages)
try {
// 1. Send message in telegram
await axios.post("/api/telegram", { message: message.body } as TAPITelegram)
// 2. Insert row in table 'tickets'
await axios.post("/api/tickets/open", {
ticketId: message.ticket_id,
ownerId: sender_id,
ownerUsername: sender_id,
messageBody: message.body,
ownerAvatarUrl: null, // TODO - getAvatarUrl() - set avatar here based on isAuthenticated
} as TAPITicketsOpen)
} catch (error) {
if (error instanceof AxiosError) {
console.log(113, "error sending message - ", error.response)
setMessages([]) // in case error delete message
setTicketId("")
}
} finally {
router.refresh()
}
}

try {
// 3. Insert message in table 'messages'
await axios.post("/api/message/send", {
id: message.id,
ticketId: message.ticket_id,
senderId: message.sender_id,
senderUsername: message.sender_id,
senderAvatarUrl: null, // TODO getAvatarUrl()
messageBody: message.body,
images: undefined,
messageSender: "user",
} as TAPIMessageSend)
} catch (error) {
console.log(74, "error inserting new message", error)
setMessages(messages.slice(0, -1)) // delete last message and keep other
}
}
1 change: 1 addition & 0 deletions app/actions/fetchTicketId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const fetchTicketIdCache = cache(async (userId: string) => {
const fetchTicketId = async (): Promise<string | undefined> => {
const userId = getUserId()
if (!userId) return undefined
if (userId.includes("anonymousId")) return userId // ticket id its anonymous id (in case !isAuthenticated)

try {
// 1 user may have only 1 open ticket - that's why single()
Expand Down
5 changes: 5 additions & 0 deletions app/api/messages/get-messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export type TAPIMessagesGetMessagesResponse = AxiosResponse<Response>
export async function POST(req: Request) {
const { ticketId, userId } = (await req.json()) as TAPIMessagesGetMessagesRequest

if (!ticketId && !userId) {
throw new Error("Either ticketId or userId must be set")
}

let ticketIdResponse: string | undefined = ticketId

if (userId) {
Expand All @@ -30,6 +34,7 @@ export async function POST(req: Request) {
ticketIdResponse = ticketId?.id
}
if (!ticketIdResponse) {
console.log(33, `no ticket with userId ${userId} - `, ticketIdResponse)
return NextResponse.json([])
}

Expand Down
3 changes: 2 additions & 1 deletion app/api/tickets/close/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export async function POST(req: Request) {

// 1. Update 'tickets' to is_open:false
const { error } = await supabaseAdmin.from("tickets").update({ is_open: false }).eq("id", ticketId)
if (error) return NextResponse.json({ error: `Error in api/tickets/route.ts\n ${error.message}` }, { status: 400 })
if (error)
return NextResponse.json({ error: `Error in api/tickets/close/route.ts\n ${error.message}` }, { status: 400 })

if (closedBy === "user") {
// On user side - clear messages
Expand Down
3 changes: 2 additions & 1 deletion app/api/tickets/open/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export async function POST(req: Request) {
owner_username: ownerUsername,
owner_avatar_url: ownerAvatarUrl,
})
if (error) return NextResponse.json({ error: `Error in api/tickets/route.ts\n ${error.message}` }, { status: 400 })
if (error)
return NextResponse.json({ error: `Error in api/tickets/open/route.ts\n ${error.message}` }, { status: 400 })

// 2. Trigger 'tickets:open' event in 'tickets' channel and pass required data
await pusherServer.trigger("tickets", "tickets:open", {
Expand Down
3 changes: 2 additions & 1 deletion app/api/tickets/rate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export async function POST(req: Request) {

// Update is_open:false and rate:rate in 'tickets'
const { error } = await supabaseAdmin.from("tickets").update({ is_open: false, rate: rate }).eq("id", ticketId)
if (error) return NextResponse.json({ error: `Error in api/tickets/route.ts\n ${error.message}` }, { status: 400 })
if (error)
return NextResponse.json({ error: `Error in api/tickets/rate/route.ts\n ${error.message}` }, { status: 400 })

return NextResponse.json({ message: "Ticket marked as completed (ticket closed)" }, { status: 200 })
}
28 changes: 2 additions & 26 deletions app/components/SupportButton/components/SupportButtonDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ import useUserStore from "@/store/user/userStore"
import { MessageBox } from "../components/MessageBox"
import { MessageInput } from "../../ui/Inputs/MessageInput"
import { IFormDataMessage } from "@/interfaces/support/IFormDataMessage"
import { sendMessage } from "@/functions/sendMessage"
import { getAnonymousId } from "@/functions/getAnonymousId"
import { useLoadInitialMessages } from "@/hooks/ui/supportButton/useLoadInitialMessages"
import { useMarkMessagesAsSeen } from "@/hooks/ui/supportButton/useMarkMessagesAsSeen"
import { useScrollToBottom } from "@/hooks/ui/supportButton/useScrollToBottom"
import useSupportDropdownClose from "@/hooks/ui/useSupportDropdownClose"
import { MarkTicketAsCompletedUser } from "../components/MarkTicketAsCompletedUser"
import { useForm } from "react-hook-form"
import { useMessagesStore } from "@/store/ui/useMessagesStore"
import { useLoading } from "@/store/ui/useLoading"
import { getPusherClient } from "@/libs/pusher"
Expand All @@ -28,15 +26,13 @@ export default function SupportButtonDropdown() {
const bottomRef = useRef<HTMLUListElement>(null)
const userStore = useUserStore()
const userId = userStore.userId || getAnonymousId()
const senderUsername = userStore.username || userId
const { ticketId, setTicketId } = useMessagesStore()
const { isLoading } = useLoading()
useLoadInitialMessages()

const { handleSubmit, register, reset, setFocus } = useForm<IFormDataMessage>()
const { messages, setMessages } = useMessagesStore()
useMarkMessagesAsSeen(isDropdown, ticketId, messages, userId, isLoading)
useScrollToBottom(setFocus, bottomRef, isDropdown)
useScrollToBottom(bottomRef, isDropdown)

useEffect(() => {
const pusherClient = getPusherClient()
Expand Down Expand Up @@ -91,22 +87,6 @@ export default function SupportButtonDropdown() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages, ticketId, router])

function sendMessageFn(data: IFormDataMessage) {
sendMessage({
data,
reset,
messages,
ticketId,
userId,
senderUsername,
avatarUrl: userStore.avatarUrl,
router,
setMessages,
setTicketId,
bottomRef,
})
}

return (
<section className="h-[400px] mobile:h-[490px] w-[280px] mobile:w-[375px] flex flex-col justify-between">
<div className="w-full shadow-md py-1 flex justify-end items-center px-2">
Expand All @@ -118,17 +98,13 @@ export default function SupportButtonDropdown() {
{isLoading ? (
<div>TODO - loading messages...</div>
) : (
<form
className="flex flex-col justify-between h-[calc(400px-56px)] mobile:h-[calc(490px-56px)]"
onSubmit={handleSubmit(sendMessageFn)}>
<form className="flex flex-col justify-between h-[calc(400px-56px)] mobile:h-[calc(490px-56px)]">
<ul className="h-[280px] mobile:h-[370px] flex flex-col gap-y-2 hide-scrollbar p-4" ref={bottomRef}>
{messages?.map(message => <MessageBox key={message.id} message={message} />)}
</ul>
<MessageInput
//-2px because it don't calculate border-width 1px
className="px-4 py-2 bg-foreground-accent shadow-md"
id="message"
register={register}
/>
</form>
)}
Expand Down
96 changes: 83 additions & 13 deletions app/components/ui/Inputs/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,101 @@
"use client"

import { UseFormRegister } from "react-hook-form"
import { useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { twMerge } from "tailwind-merge"

interface FormData {
message: string
}
import { sendChatMessageFn } from "@/(site)/functions/sendChatMessageFn"
import { getUserId } from "@/utils/getUserId"

interface MessageInputProps {
id: keyof FormData
register: UseFormRegister<FormData>
className?: string
}

export function MessageInput({ className, id, register }: MessageInputProps) {
export function MessageInput({ className }: MessageInputProps) {
const [value, setValue] = useState("")
const [height, setHeight] = useState(52) // Initialize with the base height for one line
const textareaRef = useRef<HTMLTextAreaElement>(null)
const router = useRouter()
const userId = getUserId()

// shift+enter managed by ChatGPT-4 - copy paste all code to it if issues

useEffect(() => {
// Recalculate height every time the value changes
const lineCount = value.split("\n").length

setHeight(Math.max(42, 42 + (lineCount - 1) * 24)) // Adjust height based on line count, 24px per line
}, [value])

const handleKeyDown = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter") {
if (event.shiftKey) {
event.preventDefault() // Prevent default behavior for Shift+Enter

// Insert newline at the current cursor position
const cursorPosition = event.currentTarget.selectionStart
const beforeText = value.slice(0, cursorPosition)
const afterText = value.slice(cursorPosition)
const newValue = `${beforeText}\n${afterText}`
setValue(newValue) // Update value to trigger height recalculation

setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = textareaRef.current.selectionEnd = cursorPosition + 1
// Adjust scrollTop to ensure the new line and cursor are visible
ensureCursorVisibility(textareaRef.current)
textareaRef.current.scrollTop = textareaRef.current.scrollHeight // scroll to bottom
}
}, 0)
} else {
event.preventDefault() // Prevent default form submission on Enter
// Trim and check if the message is not just spaces or newlines
if (value.trim().length > 0) {
setValue("") // Clear the textarea after sending the message
setHeight(36) // Reset height to initial value after message is sent
await sendChatMessageFn(value.trim(), userId, router)
}
}
}
}

const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(event.target.value)
}

// Ensure the cursor is visible in the textarea, adjusting scroll if necessary
function ensureCursorVisibility(textarea: HTMLTextAreaElement) {
const lineHeight = 24 // Assuming line height is 24px
const { scrollHeight, clientHeight, scrollTop } = textarea
const cursorPosition = textarea.selectionStart
const cursorLine = textarea.value.substring(0, cursorPosition).split("\n").length
const topLineVisible = Math.ceil(scrollTop / lineHeight) + 1
const bottomLineVisible = topLineVisible + Math.floor(clientHeight / lineHeight) - 1

if (cursorLine < topLineVisible || cursorLine > bottomLineVisible) {
// Align the cursor line to the bottom of the visible area
const newScrollTop = (cursorLine - Math.floor(clientHeight / lineHeight)) * lineHeight
textarea.scrollTop = newScrollTop
}
}

return (
<div className="w-[calc(100%-2px)] bg-foreground-accent p-4">
<input
<textarea
ref={textareaRef}
className={twMerge(
`w-full rounded border border-solid bg-transparent px-4 py-2 mb-1 outline-none text-title`,
`w-full !max-h-[61px] min-h-[36px] resize-none hide-scrollbar rounded border border-solid bg-transparent px-4 py-2 mb-1 outline-none text-title`,
className,
)}
id={id}
tabIndex={0}
placeholder="Enter message..."
{...register(id)}
/>
autoFocus
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
style={{
overflowY: "auto",
height: `${height}px`, // Use state to manage dynamic height
}}></textarea>
</div>
)
}
2 changes: 1 addition & 1 deletion app/functions/getAnonymousId.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCookie, setCookie } from "@/utils/helpersCSR"
import { getCookie } from "@/utils/helpersCSR"

export function getAnonymousId(): string | undefined {
const anonymousId = getCookie("anonymousId")
Expand Down
Loading

0 comments on commit 12d083c

Please sign in to comment.