- MessageItem: shadcn Avatar (square, brutalist), Badge for AGT/type labels - Tooltip on timestamps: tap/hover shows full date + seq number - ComposeBox: typing @ opens mention autocomplete popup - Arrow keys to navigate, Enter/Tab to select, Escape to close - Shows username, display name, role - Fetches user list on mount - More breathing room in messages (py-3/4) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import type { MessageType } from "@/types/MessageType";
|
|
import type { User } from "@/types/User";
|
|
import { postMessage, getUsers } from "@/api";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface Props {
|
|
channelId: string;
|
|
replyTo: string | null;
|
|
onClearReply: () => void;
|
|
onMessageSent: () => void;
|
|
}
|
|
|
|
const TYPE_META: Record<MessageType, { prefix: string; color: string; key: string }> = {
|
|
text: { prefix: ">", color: "text-muted-foreground", key: "1" },
|
|
code: { prefix: "//", color: "text-[var(--color-msg-code)]", key: "2" },
|
|
result: { prefix: "=>", color: "text-[var(--color-msg-result)]", key: "3" },
|
|
error: { prefix: "!!", color: "text-[var(--color-msg-error)]", key: "4" },
|
|
plan: { prefix: "::", color: "text-[var(--color-msg-plan)]", key: "5" },
|
|
};
|
|
|
|
export function ComposeBox({
|
|
channelId,
|
|
replyTo,
|
|
onClearReply,
|
|
onMessageSent,
|
|
}: Props) {
|
|
const [content, setContent] = useState("");
|
|
const [msgType, setMsgType] = useState<MessageType>("text");
|
|
const [sending, setSending] = useState(false);
|
|
const [focused, setFocused] = useState(false);
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [mentionOpen, setMentionOpen] = useState(false);
|
|
const [mentionQuery, setMentionQuery] = useState("");
|
|
const [mentionIdx, setMentionIdx] = useState(0);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const meta = TYPE_META[msgType];
|
|
|
|
// Load users for mention autocomplete
|
|
useEffect(() => {
|
|
getUsers().then(setUsers).catch(() => {});
|
|
}, []);
|
|
|
|
// Auto-resize textarea
|
|
useEffect(() => {
|
|
if (inputRef.current) {
|
|
inputRef.current.style.height = "0";
|
|
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px`;
|
|
}
|
|
}, [content]);
|
|
|
|
// Auto-focus on mount
|
|
useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
// Detect @mention typing
|
|
const checkMention = useCallback((text: string, cursorPos: number) => {
|
|
const before = text.slice(0, cursorPos);
|
|
const match = before.match(/@(\w*)$/);
|
|
if (match) {
|
|
setMentionOpen(true);
|
|
setMentionQuery(match[1].toLowerCase());
|
|
setMentionIdx(0);
|
|
} else {
|
|
setMentionOpen(false);
|
|
}
|
|
}, []);
|
|
|
|
const filteredUsers = users.filter((u) =>
|
|
u.username.toLowerCase().includes(mentionQuery) ||
|
|
u.display_name.toLowerCase().includes(mentionQuery)
|
|
);
|
|
|
|
function insertMention(username: string) {
|
|
const textarea = inputRef.current;
|
|
if (!textarea) return;
|
|
const pos = textarea.selectionStart;
|
|
const before = content.slice(0, pos);
|
|
const after = content.slice(pos);
|
|
const mentionStart = before.lastIndexOf("@");
|
|
const newContent = before.slice(0, mentionStart) + `@${username} ` + after;
|
|
setContent(newContent);
|
|
setMentionOpen(false);
|
|
textarea.focus();
|
|
}
|
|
|
|
async function handleSend() {
|
|
if (!content.trim() || sending) return;
|
|
setSending(true);
|
|
try {
|
|
await postMessage(channelId, {
|
|
content: content.trim(),
|
|
type: msgType,
|
|
reply_to: replyTo ?? undefined,
|
|
});
|
|
setContent("");
|
|
setMsgType("text");
|
|
onClearReply();
|
|
onMessageSent();
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
function cycleType(direction: 1 | -1) {
|
|
const types: MessageType[] = ["text", "code", "result", "error", "plan"];
|
|
const idx = types.indexOf(msgType);
|
|
const next = (idx + direction + types.length) % types.length;
|
|
setMsgType(types[next]);
|
|
}
|
|
|
|
return (
|
|
<div className={cn(
|
|
"transition-colors px-4 py-4 md:px-6 md:py-5 pb-[env(safe-area-inset-bottom,16px)]",
|
|
focused ? "bg-card" : "bg-background",
|
|
)}>
|
|
{/* Reply chip */}
|
|
{replyTo && (
|
|
<div className="flex items-center gap-1.5 mb-1.5 text-[10px] font-mono">
|
|
<span className="text-primary">^</span>
|
|
<span className="text-muted-foreground">#{replyTo.slice(0, 8)}</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClearReply}
|
|
className="text-muted-foreground hover:text-primary ml-1"
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mention autocomplete */}
|
|
{mentionOpen && filteredUsers.length > 0 && (
|
|
<div className="mb-2 border-2 border-border bg-card">
|
|
{filteredUsers.map((u, i) => (
|
|
<button
|
|
type="button"
|
|
key={u.id}
|
|
onMouseDown={(e) => { e.preventDefault(); insertMention(u.username); }}
|
|
className={cn(
|
|
"w-full text-left px-3 py-2 text-xs font-mono flex items-center gap-2 transition-colors",
|
|
i === mentionIdx ? "bg-primary/15 text-primary" : "text-foreground hover:bg-muted",
|
|
)}
|
|
>
|
|
<span className="font-bold">@{u.username}</span>
|
|
<span className="text-muted-foreground">{u.display_name}</span>
|
|
<span className="text-[9px] text-muted-foreground/50 uppercase">{u.role}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Floating input container */}
|
|
<div className={cn(
|
|
"border-2 transition-colors px-4 py-3 md:px-5 md:py-4",
|
|
focused ? "border-primary/40 bg-muted/30" : "border-border bg-muted/10",
|
|
)}>
|
|
{/* Textarea */}
|
|
<textarea
|
|
ref={inputRef}
|
|
value={content}
|
|
onChange={(e) => {
|
|
setContent(e.target.value);
|
|
checkMention(e.target.value, e.target.selectionStart);
|
|
}}
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={() => { setFocused(false); setTimeout(() => setMentionOpen(false), 200); }}
|
|
onKeyDown={(e) => {
|
|
// Mention navigation
|
|
if (mentionOpen && filteredUsers.length > 0) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setMentionIdx((i) => Math.min(i + 1, filteredUsers.length - 1));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setMentionIdx((i) => Math.max(i - 1, 0));
|
|
return;
|
|
}
|
|
if (e.key === "Enter" || e.key === "Tab") {
|
|
e.preventDefault();
|
|
insertMention(filteredUsers[mentionIdx].username);
|
|
return;
|
|
}
|
|
if (e.key === "Escape") {
|
|
setMentionOpen(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
if (e.key === "Tab" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
cycleType(1);
|
|
}
|
|
if (e.key === "Tab" && e.shiftKey) {
|
|
e.preventDefault();
|
|
cycleType(-1);
|
|
}
|
|
if (e.ctrlKey && e.key >= "1" && e.key <= "5") {
|
|
e.preventDefault();
|
|
const types: MessageType[] = ["text", "code", "result", "error", "plan"];
|
|
setMsgType(types[parseInt(e.key) - 1]);
|
|
}
|
|
}}
|
|
placeholder="message"
|
|
disabled={sending}
|
|
rows={1}
|
|
className={cn(
|
|
"w-full bg-transparent text-sm font-mono text-foreground placeholder:text-muted-foreground/30 resize-none focus:outline-none leading-relaxed",
|
|
sending && "opacity-30",
|
|
)}
|
|
/>
|
|
|
|
{/* Bottom bar — prefix + type indicator */}
|
|
<div className="flex items-center justify-between mt-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => cycleType(1)}
|
|
className={cn(
|
|
"font-mono text-xs font-bold transition-colors select-none",
|
|
meta.color,
|
|
)}
|
|
title={`${msgType} (Tab to cycle)`}
|
|
>
|
|
{meta.prefix}
|
|
</button>
|
|
|
|
<span className={cn(
|
|
"text-[9px] font-mono uppercase tracking-[0.2em] transition-opacity",
|
|
meta.color,
|
|
content ? "opacity-60" : "opacity-25",
|
|
)}>
|
|
{msgType} · enter to send
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|