diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index 3062001..7024f45 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -386,7 +386,7 @@ export default function Chat({ user }: { user: ChatUserShape }) { value={messageSearch} onChange={(e) => setMessageSearch(e.target.value)} placeholder="Search Message..." - className="w-full bg-gray-50 border border-transparent rounded-xl py-2 pl-9 pr-4 text-sm text-gray-700 placeholder:text-gray-500 outline-none focus:border-blue-500/50 transition-colors shadow-inner" + className="w-full bg-gray-50 border border-transparent rounded py-2 pl-9 pr-4 text-sm text-gray-700 placeholder:text-gray-500 outline-none focus:border-blue-500/50 transition-colors" /> @@ -550,13 +550,13 @@ export default function Chat({ user }: { user: ChatUserShape }) { )} {/* Input Area */} -
+
{attachments.length > 0 && (
{attachments.map((file, index) => (
{file.type.startsWith("image/") ? ( - - - -
diff --git a/app/components/chat/Messages.tsx b/app/components/chat/Messages.tsx index b8f3b0e..363236a 100644 --- a/app/components/chat/Messages.tsx +++ b/app/components/chat/Messages.tsx @@ -12,6 +12,12 @@ import { faFile, faPause, faPlay } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import MediaViewerModal, { type MediaViewerPayload } from "./MediaViewerModal"; +type NormalizedAttachment = { + mimetype: string; + public_url: string; + filename: string; +}; + export default function Messages({ messages, user, @@ -49,8 +55,8 @@ export default function Messages({ if (!container) return; const handleScroll = () => { const nearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < - 100; + container.scrollHeight - container.scrollTop - container.clientHeight; + 100; setShowScrollBtn(!nearBottom); }; container.addEventListener("scroll", handleScroll); @@ -90,199 +96,26 @@ export default function Messages({ )} {messages.map((msg, idx) => { - const isSelf = msg.sender_id === user.id; - const conversationRow = conversations.find( - (c) => c.id === msg.conversation_id, - ); - const senderRow = conversationRow?.users.find( - (u) => u.id === msg.sender_id, - ); - - const senderInitial = senderRow?.email?.[0]?.toUpperCase() ?? "?"; - const senderName = senderRow?.email?.split("@")?.[0] ?? ""; - const canOpenPrivateChat = - !isSelf && conversationRow?.type === "global" && !!senderRow?.email; - - const badgeInfo = badgesByUserId?.[msg.sender_id] ?? fallbackBadge; - const badgeLabel = badgeInfo.label; - const badgePillClass = badgeInfo.className; - - // long msg? nudge avatar up, ez. - const text = msg.text ?? ""; - const hasMedia = !!msg.attachments?.length; - const isLongMessage = - hasMedia || text.length >= 120 || text.includes("\n"); - const avatarTranslateClass = isLongMessage - ? "-translate-y-[4.5px]" - : "-translate-y-[4px]"; - const normalizedAttachments = (msg.attachments ?? []) - .map((att) => normalizeAttachment(att)) - .filter( - ( - att, - ): att is { - mimetype: string; - public_url: string; - filename: string; - } => !!att, - ); - const isVoiceOnlyMessage = - !msg.text && - normalizedAttachments.length > 0 && - normalizedAttachments.every( - (att) => getAttachmentKind(att) === "audio", - ); + const prevMsg = idx > 0 ? messages[idx - 1] : null; + // Only collapse the header when the previous message is from the + // same sender AND within the same conversation thread. + const isGrouped = + !!prevMsg && + prevMsg.sender_id === msg.sender_id && + prevMsg.conversation_id === msg.conversation_id; return ( -
- {!isSelf && ( -
{ - if (!canOpenPrivateChat || !senderRow?.email) return; - onUserProfileClick?.(msg.sender_id, senderRow.email); - }} - onKeyDown={(event) => { - if (!canOpenPrivateChat || !senderRow?.email) return; - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onUserProfileClick?.(msg.sender_id, senderRow.email); - }} - title={canOpenPrivateChat ? "Start private chat" : undefined} - className={`flex-shrink-0 ${avatarTranslateClass} w-8 h-8 rounded-full bg-neutral-700 border border-gray-200 flex items-center justify-center aspect-square overflow-hidden ${ - canOpenPrivateChat - ? "cursor-pointer hover:border-blue-400/60 hover:bg-neutral-700/80" - : "" - }`} - > - - {senderInitial} - -
- )} - -
-
- {isSelf && ( - - {timeAgo(msg.created_at)} - - )} - {canOpenPrivateChat && senderRow?.email ? ( - - ) : ( - - {senderName} - - )} - - {badgeInfo.icon && ( - - )} - {badgeLabel} - - {!isSelf && ( - - {timeAgo(msg.created_at)} - - )} -
- - {msg.text && ( -
-
- )} - > - {children} - - ); - } - - return ( - } - /> - ); - }, - }} - > - {msg.text} - -
-
- )} - - {normalizedAttachments.length > 0 && ( -
- {normalizedAttachments.map((att, i) => ( -
- {getAttachments(att, (payload) => - setMediaViewer(payload), - )} -
- ))} -
- )} -
-
+ msg={msg} + user={user} + conversations={conversations} + badgesByUserId={badgesByUserId} + fallbackBadge={fallbackBadge} + showHeader={!isGrouped} + onUserProfileClick={onUserProfileClick} + onOpenMedia={setMediaViewer} + /> ); })} @@ -292,6 +125,205 @@ export default function Messages({ ); } +function MessageRow({ + msg, + user, + conversations, + badgesByUserId, + fallbackBadge, + showHeader, + onUserProfileClick, + onOpenMedia, +}: { + msg: Message; + user: ChatUserShape; + conversations: Conversation[]; + badgesByUserId?: Record; + fallbackBadge: BadgeInfo; + showHeader: boolean; + onUserProfileClick?: (targetuser_id: string, targetEmail: string) => void; + onOpenMedia: (payload: MediaViewerPayload) => void; +}) { + const isSelf = msg.sender_id === user.id; + const conversationRow = conversations.find( + (c) => c.id === msg.conversation_id, + ); + const senderRow = conversationRow?.users.find((u) => u.id === msg.sender_id); + + const senderInitial = senderRow?.email?.[0]?.toUpperCase() ?? "?"; + const senderName = senderRow?.email?.split("@")?.[0] ?? ""; + const canOpenPrivateChat = + !isSelf && conversationRow?.type === "global" && !!senderRow?.email; + + const badgeInfo = badgesByUserId?.[msg.sender_id] ?? fallbackBadge; + + const text = msg.text ?? ""; + const hasMedia = !!msg.attachments?.length; + const isLongMessage = hasMedia || text.length >= 120 || text.includes("\n"); + const avatarTranslateClass = isLongMessage + ? "-translate-y-[4.5px]" + : "-translate-y-[4px]"; + + const normalizedAttachments = (msg.attachments ?? []) + .map(normalizeAttachment) + .filter((a): a is NormalizedAttachment => !!a); + + const isVoiceOnlyMessage = + !msg.text && + normalizedAttachments.length > 0 && + normalizedAttachments.every((att) => getAttachmentKind(att) === "audio"); + + const handleProfileClick = () => { + if (!canOpenPrivateChat || !senderRow?.email) return; + onUserProfileClick?.(msg.sender_id, senderRow.email); + }; + + return ( +
+ {!isSelf && ( +
{ + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handleProfileClick(); + }} + title={canOpenPrivateChat ? "Start private chat" : undefined} + className={`flex-shrink-0 ${avatarTranslateClass} w-8 h-8 rounded-full bg-neutral-700 border border-gray-200 flex items-center justify-center aspect-square overflow-hidden ${ + showHeader ? "" : "invisible" + } ${ + canOpenPrivateChat + ? "cursor-pointer hover:border-blue-400/60 hover:bg-neutral-700/80" + : "" + }`} + > + + {senderInitial} + +
+ )} + +
+ {showHeader && ( +
+ {isSelf && ( + + {timeAgo(msg.created_at)} + + )} + {canOpenPrivateChat && senderRow?.email ? ( + + ) : ( + + {senderName} + + )} + + {badgeInfo.icon && ( + + )} + {badgeInfo.label} + + {!isSelf && ( + + {timeAgo(msg.created_at)} + + )} +
+ )} + + {msg.text && } + + {normalizedAttachments.length > 0 && ( +
+ {normalizedAttachments.map((att, i) => ( +
+ +
+ ))} +
+ )} +
+
+ ); +} + +function MessageBubble({ text, isSelf }: { text: string; isSelf: boolean }) { + return ( +
+
+ )} + > + {children} + + ); + } + + return ( + } + /> + ); + }, + }} + > + {text} + +
+
+ ); +} + function CodeBlock({ code, language, @@ -353,18 +385,13 @@ function CodeBlock({ ); } -function getAttachments( - attachment: { - mimetype: string; - public_url: string; - filename: string; - }, - onOpenMedia: (payload: { - type: "image" | "video"; - url: string; - filename: string; - }) => void, -) { +function AttachmentView({ + attachment, + onOpenMedia, +}: { + attachment: NormalizedAttachment; + onOpenMedia: (payload: MediaViewerPayload) => void; +}) { const kind = getAttachmentKind(attachment); switch (kind) { @@ -467,7 +494,6 @@ function getAttachmentKind(attachment: { if (/\.(mp3|wav|ogg|m4a|aac|flac|weba|opus|amr)(\?|$)/.test(source)) return "audio"; - // Mobile voice uploads can end up as generic binary mime. if ( mime.includes("octet-stream") && (source.includes("audio") || @@ -477,7 +503,6 @@ function getAttachmentKind(attachment: { return "audio"; } - // Favor voice-note UX for unknown media blobs unless clearly image/video. if (!mime || mime.includes("octet-stream") || mime.includes("application/")) { return "audio"; } @@ -485,11 +510,7 @@ function getAttachmentKind(attachment: { return "file"; } -function normalizeAttachment(raw: unknown): { - mimetype: string; - public_url: string; - filename: string; -} | null { +function normalizeAttachment(raw: unknown): NormalizedAttachment | null { if (!raw || typeof raw !== "object") return null; const obj = raw as Record; @@ -522,11 +543,7 @@ function normalizeAttachment(raw: unknown): { "attachment", ).trim(); - return { - mimetype, - public_url, - filename, - }; + return { mimetype, public_url, filename }; } function AudioAttachmentPlayer({ src, type }: { src: string; type: string }) { @@ -618,14 +635,12 @@ function AudioAttachmentPlayer({ src, type }: { src: string; type: string }) { const md3WaveData = useMemo(() => { if (!waveData.length) return []; - // Smooth neighboring peaks so waveform looks cleaner (MD3-like, less jagged). const smooth = waveData.map((v, i) => { const a = waveData[Math.max(0, i - 1)] ?? v; const b = v; const c = waveData[Math.min(waveData.length - 1, i + 1)] ?? v; const d = waveData[Math.min(waveData.length - 1, i + 2)] ?? c; - const weighted = a * 0.18 + b * 0.44 + c * 0.28 + d * 0.1; - return weighted; + return a * 0.18 + b * 0.44 + c * 0.28 + d * 0.1; }); const min = Math.min(...smooth); @@ -635,7 +650,6 @@ function AudioAttachmentPlayer({ src, type }: { src: string; type: string }) { return smooth.map((v, i) => { const normalized = (v - min) / range; const shaped = Math.pow(normalized, 0.95); - // Gentle center emphasis like modern voice notes. const pos = i / Math.max(1, smooth.length - 1); const centerBoost = 0.88 + 0.18 * (1 - Math.abs(pos - 0.5) * 2); return 7 + shaped * 10 * centerBoost; @@ -707,7 +721,6 @@ function AudioAttachmentPlayer({ src, type }: { src: string; type: string }) { const maxPeak = Math.max(0.0001, ...rawPeaks); const next = rawPeaks.map((p) => { const normalized = p / maxPeak; - // Gamma-ish curve gives clearer separation for low-dynamic voice notes. const shaped = Math.pow(normalized, 0.65); return Math.max(8, Math.min(24, Math.round(8 + shaped * 16))); });