fix: chat UI

This commit is contained in:
gosangam
2026-08-29 17:30:32 +05:30
parent 3e6a1c6f39
commit 011b5bba91
11 changed files with 250 additions and 76 deletions
+4 -1
View File
@@ -135,7 +135,10 @@ export function MessageGestures({
return (
<div
className="relative"
// Full width so the whole row is draggable — WhatsApp lets you start the
// gesture anywhere on the line, including the empty space opposite the
// bubble. The bubble caps its own width inside this.
className="relative w-full"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
+84 -59
View File
@@ -81,7 +81,7 @@ function QuotedReply({
export function MessageGroup({
messages,
mine,
peer,
sender,
receipts,
showAvatar,
selfId,
@@ -93,7 +93,9 @@ export function MessageGroup({
}: {
messages: ChatMessage[];
mine: boolean;
peer?: ChatPeer | null;
/** Who sent this group of messages. Undefined for Kautilya, system
* messages, or a member who has since left. */
sender?: ChatPeer;
receipts: Receipts | null;
/** Groups show an avatar; a DM's own side never does. */
showAvatar: boolean;
@@ -119,20 +121,31 @@ export function MessageGroup({
>
{!mine && showAvatar && (
<UserAvatar
src={peer?.avatar_url}
name={peer?.name ?? "?"}
src={sender?.avatar_url}
name={sender?.name ?? "?"}
className="size-7 shrink-0"
textClassName="text-[10px]"
/>
)}
<div
// flex-1 rather than max-w: the column spans the row so a swipe can
// start anywhere on it. min-w-0 lets long words wrap instead of
// widening the row.
className={cn(
"flex max-w-[78%] flex-col gap-0.5",
"flex min-w-0 flex-1 flex-col gap-0.5",
mine ? "items-end" : "items-start",
!mine && !showAvatar && "ml-9",
)}
>
{/* Name the sender once per run of messages. An avatar alone is thin
identification in a group — two people with similar photos, or
none at all, are indistinguishable at 28px. */}
{!mine && showAvatar && (
<span className="px-1 text-[11px] font-medium text-muted-foreground">
{sender?.name ?? "Unknown"}
</span>
)}
{messages.map((m, i) => (
// A deleted message has nothing to quote, so it isn't swipeable.
<MessageGestures
@@ -143,62 +156,73 @@ export function MessageGroup({
>
<div
className={cn(
"group/bubble relative px-3 py-2 text-sm",
bubbleCorners(mine, i, messages.length),
m.deleted_at
? "bg-muted italic text-muted-foreground"
: mine
? "bg-primary text-primary-foreground dark:text-white"
: "bg-muted text-foreground",
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
{m.reply_to && !m.deleted_at && (
<QuotedReply
reply={m.reply_to}
mine={mine}
selfId={selfId}
nameFor={nameFor}
/>
)}
{m.deleted_at ? (
<span>This message was deleted</span>
) : (
<>
{m.body && <MessageText body={m.body} entities={m.entities} />}
<MessageAttachments attachments={m.attachments} />
{/* An optimistic bubble for a captionless attachment has
<div
className={cn(
"group/bubble relative max-w-[78%] px-3 py-2 text-sm",
bubbleCorners(mine, i, messages.length),
m.deleted_at
? "bg-muted italic text-muted-foreground"
: mine
? "bg-primary text-primary-foreground dark:text-white"
: "bg-muted text-foreground",
)}
>
{m.reply_to && !m.deleted_at && (
<QuotedReply
reply={m.reply_to}
mine={mine}
selfId={selfId}
nameFor={nameFor}
/>
)}
{m.deleted_at ? (
<span>This message was deleted</span>
) : (
<>
{m.body && (
<MessageText body={m.body} entities={m.entities} />
)}
<MessageAttachments attachments={m.attachments} />
{/* An optimistic bubble for a captionless attachment has
nothing to draw until the server answers — say so rather
than render an empty box. */}
{m.pending && !m.body && m.attachments.length === 0 && (
<span className="italic opacity-80">Sending attachment…</span>
)}
{starredIds.has(m.id) && (
<Star
className="mt-1 size-3 fill-current opacity-70"
aria-label="Starred"
/>
)}
</>
)}
{m.pending && !m.body && m.attachments.length === 0 && (
<span className="italic opacity-80">
Sending attachment…
</span>
)}
{starredIds.has(m.id) && (
<Star
className="mt-1 size-3 fill-current opacity-70"
aria-label="Starred"
/>
)}
</>
)}
{/* Desktop affordance. Swiping is the mobile gesture, but a
{/* Desktop affordance. Swiping is the mobile gesture, but a
drag is not discoverable with a mouse — and a real button is
also reachable by keyboard, which a gesture never is. */}
{!m.deleted_at && !m.pending && (
<button
type="button"
onClick={() => onReply(m)}
aria-label="Reply to this message"
className={cn(
"absolute top-1/2 hidden -translate-y-1/2 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm",
"opacity-0 transition-opacity group-hover/bubble:opacity-100 focus-visible:opacity-100",
"hover:text-foreground sm:block",
mine ? "-left-9" : "-right-9",
)}
>
<Reply className="size-3.5" />
</button>
)}
{!m.deleted_at && !m.pending && (
<button
type="button"
onClick={() => onReply(m)}
aria-label="Reply to this message"
className={cn(
"absolute top-1/2 hidden -translate-y-1/2 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm",
"opacity-0 transition-opacity group-hover/bubble:opacity-100 focus-visible:opacity-100",
"hover:text-foreground sm:block",
mine ? "-left-9" : "-right-9",
)}
>
<Reply className="size-3.5" />
</button>
)}
</div>
</div>
</MessageGestures>
))}
@@ -223,8 +247,10 @@ export function MessageGroup({
Not sent — retry
</button>
)}
{mine && !last.deleted_at && !last.failed && (
last.pending ? (
{mine &&
!last.deleted_at &&
!last.failed &&
(last.pending ? (
<Clock
role="img"
aria-label="Sending"
@@ -232,8 +258,7 @@ export function MessageGroup({
/>
) : (
<MessageTicks state={tickState(last.seq, receipts)} />
)
)}
))}
</div>
</div>
</div>
+8 -4
View File
@@ -44,7 +44,7 @@ function dayLabel(iso: string): string {
export function MessageList({
messages,
selfId,
peer,
senderFor,
receipts,
isGroup,
hasMore,
@@ -59,7 +59,8 @@ export function MessageList({
/** Ascending by seq — oldest first. */
messages: ChatMessage[];
selfId: string;
peer?: ChatPeer | null;
/** Resolves a message's sender for their avatar and name. */
senderFor: (senderId: string | null | undefined) => ChatPeer | undefined;
receipts: Receipts | null;
isGroup: boolean;
hasMore: boolean;
@@ -133,7 +134,10 @@ export function MessageList({
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
// overflow-x-hidden is load-bearing: `overflow-y: auto` computes the
// other axis to `auto` rather than `visible`, so the swipe-to-reply
// transform would otherwise give the thread a horizontal scrollbar.
className="flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-4"
>
{loadingMore && (
<p className="pb-3 text-center text-xs text-muted-foreground">
@@ -160,7 +164,7 @@ export function MessageList({
key={row.key}
messages={row.messages}
mine={row.senderId === selfId}
peer={peer}
sender={senderFor(row.senderId)}
receipts={receipts}
showAvatar={isGroup}
selfId={selfId}