ui: Avatar + Badge + Tooltip + @mention autocomplete
- 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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { MessageType } from "@/types/MessageType";
|
||||
import { postMessage } from "@/api";
|
||||
import type { User } from "@/types/User";
|
||||
import { postMessage, getUsers } from "@/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
@@ -28,9 +29,18 @@ export function ComposeBox({
|
||||
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) {
|
||||
@@ -44,6 +54,37 @@ export function ComposeBox({
|
||||
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);
|
||||
@@ -89,6 +130,27 @@ export function ComposeBox({
|
||||
</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",
|
||||
@@ -98,10 +160,36 @@ export function ComposeBox({
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value);
|
||||
checkMention(e.target.value, e.target.selectionStart);
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user