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
133 changes: 133 additions & 0 deletions web/app/components/ChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { ChangeEvent, RefObject } from "react";
import { ImagePlus, Send, X } from "lucide-react";
import {
CHAT_ATTACHMENT_SETTINGS,
formatMaxImageSizeLabel,
} from "~/services/chat_settings";

interface ChatComposerProps {
newMessage: string;
setNewMessage: (value: string) => void;
selectedImage: File | null;
selectedImagePreviewUrl: string;
composerError: string;
isSending: boolean;
fileInputRef: RefObject<HTMLInputElement | null>;
handleSelectImage: () => void;
handleImageInputChange: (e: ChangeEvent<HTMLInputElement>) => void;
clearSelectedImage: () => void;
sendMessage: () => Promise<void>;
}

/** Textarea, image picker + preview, and send button for the chat panel. */
export default function ChatComposer({
newMessage,
setNewMessage,
selectedImage,
selectedImagePreviewUrl,
composerError,
isSending,
fileInputRef,
handleSelectImage,
handleImageInputChange,
clearSelectedImage,
sendMessage,
}: ChatComposerProps) {
return (
<div className="mt-auto">
<div className="h-px w-full bg-[#D9D9D9]" />

{selectedImage ? (
<div className="mt-4 rounded-2xl border border-[#D9D9D9] bg-[#FAFAFA] p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-black">
Image ready to send
</p>
<p className="text-xs text-[#666666]">
{selectedImage.name}
</p>
</div>

<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md border border-[#D9D9D9] bg-white text-black"
aria-label="Remove selected image"
onClick={clearSelectedImage}
disabled={isSending}
>
<X size={16} />
</button>
</div>

{selectedImagePreviewUrl ? (
<img
src={selectedImagePreviewUrl}
alt="Selected attachment preview"
className="mt-3 max-h-56 rounded-xl object-cover"
/>
) : null}
</div>
) : null}

<div className="mt-4 flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept={CHAT_ATTACHMENT_SETTINGS.acceptedMimeTypes.join(
","
)}
className="hidden"
onChange={handleImageInputChange}
/>

<input
type="text"
placeholder="Type message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void sendMessage();
}
}}
className="h-12 flex-1 rounded-2xl border-0 bg-[#F0F0F0] px-4 text-base text-black placeholder:italic placeholder:text-[#999999] focus:outline-none"
/>

<button
type="button"
className="flex h-12 w-12 items-center justify-center rounded-md bg-black text-white"
aria-label="Add photo"
onClick={handleSelectImage}
disabled={isSending}
>
<ImagePlus size={22} />
</button>

<button
type="button"
className="flex h-12 w-12 items-center justify-center rounded-md bg-black text-white"
aria-label="Send message"
onClick={() => void sendMessage()}
disabled={
isSending || (!newMessage.trim() && !selectedImage)
}
>
<Send size={22} />
</button>
</div>

<div className="mt-3 flex items-center justify-between gap-4 text-xs text-[#666666]">
<p>
One image per message. Max {formatMaxImageSizeLabel()}.
Allowed:{" "}
{CHAT_ATTACHMENT_SETTINGS.acceptedFileExtensionsLabel}
</p>
{composerError ? (
<p className="text-right text-[#B42318]">{composerError}</p>
) : null}
</div>
</div>
);
}
126 changes: 126 additions & 0 deletions web/app/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { UIEvent } from "react";
import MessageBubble from "~/components/MessageBubble";
import ChatComposer from "~/components/ChatComposer";
import { useChat } from "~/hooks/useChat";

interface ChatPanelProps {
userId: string;
chatUserFullName: string;
currentUserName: string;
}

/** Chat column: heading, scrollable message list, and composer. */
export default function ChatPanel({
userId,
chatUserFullName,
currentUserName,
}: ChatPanelProps) {
const {
messages,
newMessage,
setNewMessage,
selectedImage,
selectedImagePreviewUrl,
composerError,
isSending,
isLoadingMessages,
isLoadingOlder,
senderProfiles,
messageContainerRef,
fileInputRef,
loadOlderMessages,
clearSelectedImage,
sendMessage,
handleSelectImage,
handleImageInputChange,
} = useChat(userId, currentUserName);

function handleMessagesScroll(e: UIEvent<HTMLDivElement>) {
if (e.currentTarget.scrollTop <= 80) {
void loadOlderMessages();
}
}

return (
<div className="flex w-[50vw] min-w-[360px] max-w-[600px] flex-col">
<h2 className="mb-3 text-right text-[24px] font-semibold leading-tight text-black">
Chat with {chatUserFullName}
</h2>

<section className="flex flex-1 flex-col overflow-hidden bg-white p-6 shadow-[0_4px_12px_rgba(0,0,0,0.12)]">
<div
ref={messageContainerRef}
onScroll={handleMessagesScroll}
className="flex-1 overflow-y-auto pr-1"
>
{isLoadingOlder ? (
<p className="pb-3 text-center text-sm text-[#999999]">
Loading older messages...
</p>
) : null}

{!isLoadingMessages && messages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-[#999999]">
No messages yet!
</p>
</div>
) : null}

<div className="pb-6">
{isLoadingMessages ? (
<p className="text-center text-sm text-[#999999]">
Loading messages...
</p>
) : null}

{messages.map((message, index) => {
const isSent = message.direction === "sent";
const isPatient = message.senderId === userId;
const nextMessage = messages[index + 1];
const previousMessage = messages[index - 1];
const startsChunk =
!previousMessage ||
previousMessage.senderId !== message.senderId;
const endsChunk =
!nextMessage ||
nextMessage.senderId !== message.senderId;
const senderLabel = isSent
? currentUserName || message.senderName || "You"
: isPatient
? chatUserFullName
: message.senderName ||
senderProfiles[message.senderId] ||
message.senderId ||
"Unknown";

return (
<MessageBubble
key={message.id}
message={message}
senderLabel={senderLabel}
startsChunk={startsChunk}
endsChunk={endsChunk}
/>
);
})}
</div>
</div>

<ChatComposer
newMessage={newMessage}
setNewMessage={setNewMessage}
selectedImage={selectedImage}
selectedImagePreviewUrl={selectedImagePreviewUrl}
composerError={composerError}
isSending={isSending}
fileInputRef={fileInputRef}
handleSelectImage={handleSelectImage}
handleImageInputChange={handleImageInputChange}
clearSelectedImage={clearSelectedImage}
sendMessage={sendMessage}
/>
</section>
</div>
);
}
69 changes: 69 additions & 0 deletions web/app/components/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ChatMessage } from "~/hooks/useChat";

interface MessageBubbleProps {
message: ChatMessage;
/** Sender label shown above the first bubble in a consecutive run. */
senderLabel: string;
/** True for the first message in a consecutive run from the same sender. */
startsChunk: boolean;
/** True for the last message in a consecutive run from the same sender. */
endsChunk: boolean;
}

/**
* A single chat bubble, grouped visually with consecutive messages from the
* same sender via `startsChunk` / `endsChunk`.
*/
export default function MessageBubble({
message,
senderLabel,
startsChunk,
endsChunk,
}: MessageBubbleProps) {
const isSent = message.direction === "sent";

return (
<div
className={`flex ${isSent ? "justify-end" : "justify-start"} ${startsChunk ? "pt-4" : "pt-0.5"}`}
>
<div
className={`max-w-[75%] ${isSent ? "items-end" : "items-start"} flex flex-col`}
>
{startsChunk && senderLabel ? (
<span className="mb-1 text-xs font-medium text-[#666666]">
{senderLabel}
</span>
) : null}
<div
className={`rounded-2xl px-4 py-3 text-[16px] ${isSent ? "bg-black text-white" : "bg-[#F0F0F0] text-black"}`}
>
{message.messageType === "image" ? (
message.imageUrl ? (
<img
src={message.imageUrl}
alt="Chat attachment"
className="max-h-72 rounded-xl object-cover"
/>
) : (
<span>Image unavailable</span>
)
) : (
message.text
)}
</div>
{endsChunk ? (
<span
className={`mt-1 text-[16px] text-[#999999] ${isSent ? "text-right" : "text-left"}`}
>
{message.timestamp}
</span>
) : null}
{endsChunk && isSent && message.isRead ? (
<span className="mt-0.5 text-xs text-[#999999] self-end">
Read
</span>
) : null}
</div>
</div>
);
}
Loading