fix(messenger): reply focuses composer + quoted message jumps to original
- Clicking Reply now focuses the composer input (was requiring a manual click). - A quoted message is clickable → scrolls to the original and flashes it briefly (was inert). Message elements register refs by id for the jump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -139,7 +139,10 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<UiMessage | null>(null);
|
||||
const [flashId, setFlashId] = useState<string | null>(null);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const msgRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const typingSentAt = useRef(0);
|
||||
|
||||
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]);
|
||||
@@ -147,6 +150,20 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
|
||||
const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]);
|
||||
const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]);
|
||||
|
||||
// Reply → focus the composer (bug: it didn't focus, forcing a manual click).
|
||||
function startReply(msg: UiMessage) {
|
||||
setReplyTo(msg);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
// Click a quoted message → scroll to the original and flash it.
|
||||
function jumpTo(id: string) {
|
||||
const el = msgRefs.current.get(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setFlashId(id);
|
||||
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1200);
|
||||
}
|
||||
|
||||
function onDraftChange(v: string) {
|
||||
setDraft(v);
|
||||
const now = Date.now();
|
||||
@@ -188,8 +205,11 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
|
||||
parent={msg.parentInteractionId ? byId[msg.parentInteractionId] : undefined}
|
||||
seen={msg.id === lastMineId && t.seenIds.has(msg.id)}
|
||||
showStatus={msg.id === lastMineId}
|
||||
flash={flashId === msg.id}
|
||||
registerRef={(el) => { if (el) msgRefs.current.set(msg.id, el); else msgRefs.current.delete(msg.id); }}
|
||||
onReact={(emoji) => t.react(msg.id, emoji)}
|
||||
onReply={() => setReplyTo(msg)}
|
||||
onReply={() => startReply(msg)}
|
||||
onQuoteClick={jumpTo}
|
||||
/>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
@@ -209,6 +229,7 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
|
||||
|
||||
<footer style={{ display: "flex", gap: 8, padding: 14, borderTop: "1px solid var(--border)" }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft} onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
|
||||
placeholder="Type a message…" style={inputStyle}
|
||||
@@ -220,24 +241,36 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
msg, parent, seen, showStatus, onReact, onReply,
|
||||
msg, parent, seen, showStatus, flash, registerRef, onReact, onReply, onQuoteClick,
|
||||
}: {
|
||||
msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean;
|
||||
onReact: (emoji: string) => void; onReply: () => void;
|
||||
msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean; flash?: boolean;
|
||||
registerRef?: (el: HTMLElement | null) => void;
|
||||
onReact: (emoji: string) => void; onReply: () => void; onQuoteClick?: (id: string) => void;
|
||||
}) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [picker, setPicker] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={registerRef}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setPicker(false); }}
|
||||
style={{ alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column", alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative" }}
|
||||
style={{
|
||||
alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column",
|
||||
alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative",
|
||||
borderRadius: 14, padding: 2, transition: "background 0.4s",
|
||||
background: flash ? "rgba(253,169,19,0.22)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{parent && (
|
||||
<div style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => parent.id && onQuoteClick?.(parent.id)}
|
||||
title="Go to message"
|
||||
style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", border: "none", borderLeftWidth: 3, fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", cursor: "pointer", textAlign: "left" }}
|
||||
>
|
||||
<span style={{ opacity: 0.8 }}>↩ {parent.text}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexDirection: msg.mine ? "row-reverse" : "row" }}>
|
||||
|
||||
Reference in New Issue
Block a user