feat(web): inline chat search and global audio bar
Some checks failed
CI / test (push) Failing after 20s
Some checks failed
CI / test (push) Failing after 20s
- replace modal message search with header inline search controls - add global top audio bar linked to active inline audio player - improve chat info header variants and light theme readability
This commit is contained in:
@@ -35,6 +35,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
const [chat, setChat] = useState<ChatDetail | null>(null);
|
||||
const [members, setMembers] = useState<ChatMember[]>([]);
|
||||
const [memberUsers, setMemberUsers] = useState<Record<number, AuthUser>>({});
|
||||
const [counterpartProfile, setCounterpartProfile] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
@@ -116,16 +117,22 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
}
|
||||
if (detail.type === "private" && !detail.is_saved && detail.counterpart_user_id) {
|
||||
try {
|
||||
const counterpart = await getUserById(detail.counterpart_user_id);
|
||||
if (!cancelled) {
|
||||
setCounterpartProfile(counterpart);
|
||||
}
|
||||
const blocked = await listBlockedUsers();
|
||||
if (!cancelled) {
|
||||
setCounterpartBlocked(blocked.some((u) => u.id === detail.counterpart_user_id));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setCounterpartProfile(null);
|
||||
setCounterpartBlocked(false);
|
||||
}
|
||||
}
|
||||
} else if (!cancelled) {
|
||||
setCounterpartProfile(null);
|
||||
setCounterpartBlocked(false);
|
||||
}
|
||||
await refreshMembers(chatId);
|
||||
@@ -204,6 +211,32 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
|
||||
{chat ? (
|
||||
<>
|
||||
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{chat.type === "private" && counterpartProfile?.avatar_url ? (
|
||||
<img alt="avatar" className="h-16 w-16 rounded-full object-cover" src={counterpartProfile.avatar_url} />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-sky-500/30 text-xl font-semibold uppercase text-sky-100">
|
||||
{initialsFromName(chat.display_title || chat.title || counterpartProfile?.name || chat.type)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold">{chatLabel(chat)}</p>
|
||||
<p className="truncate text-xs text-slate-400">
|
||||
{chat.type === "private"
|
||||
? privateChatStatusLabel(chat)
|
||||
: chat.type === "group"
|
||||
? `${chat.members_count ?? members.length} members, ${chat.online_count ?? 0} online`
|
||||
: `${chat.subscribers_count ?? chat.members_count ?? members.length} subscribers`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{chat.type === "private" && counterpartProfile?.username ? <p className="mt-3 text-xs text-sky-300">@{counterpartProfile.username}</p> : null}
|
||||
{chat.type !== "private" && chat.handle ? <p className="mt-3 text-xs text-sky-300">@{chat.handle}</p> : null}
|
||||
{chat.type === "private" && counterpartProfile?.bio ? <p className="mt-2 text-sm text-slate-300">{counterpartProfile.bio}</p> : null}
|
||||
{chat.type !== "private" && chat.description ? <p className="mt-2 text-sm text-slate-300">{chat.description}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-400">Notifications</p>
|
||||
@@ -226,37 +259,34 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-slate-300">{muted ? "Chat notifications are muted." : "Chat notifications are enabled."}</p>
|
||||
<p className="text-xs text-slate-400">Type</p>
|
||||
<p className="text-sm">{chat.type}</p>
|
||||
<p className="mt-2 text-xs text-slate-400">Title</p>
|
||||
<input
|
||||
className="mt-1 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
|
||||
disabled={!isGroupLike}
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
/>
|
||||
{isGroupLike ? (
|
||||
<button
|
||||
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
|
||||
disabled={savingTitle || !titleDraft.trim()}
|
||||
onClick={async () => {
|
||||
setSavingTitle(true);
|
||||
try {
|
||||
const updated = await updateChatTitle(chatId, titleDraft.trim());
|
||||
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||
await loadChats();
|
||||
} catch {
|
||||
setError("Failed to update title");
|
||||
} finally {
|
||||
setSavingTitle(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save title
|
||||
</button>
|
||||
<>
|
||||
<p className="mt-2 text-xs text-slate-400">Title</p>
|
||||
<input
|
||||
className="mt-1 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
|
||||
disabled={savingTitle || !titleDraft.trim()}
|
||||
onClick={async () => {
|
||||
setSavingTitle(true);
|
||||
try {
|
||||
const updated = await updateChatTitle(chatId, titleDraft.trim());
|
||||
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||
await loadChats();
|
||||
} catch {
|
||||
setError("Failed to update title");
|
||||
} finally {
|
||||
setSavingTitle(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save title
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{chat.handle ? <p className="mt-2 text-xs text-slate-300">@{chat.handle}</p> : null}
|
||||
{chat.description ? <p className="mt-1 text-xs text-slate-400">{chat.description}</p> : null}
|
||||
{isGroupLike && canManageMembers ? (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
@@ -760,6 +790,37 @@ function formatLastSeen(value: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function chatLabel(chat: { display_title?: string | null; title?: string | null; type: "private" | "group" | "channel"; is_saved?: boolean }): string {
|
||||
if (chat.display_title?.trim()) return chat.display_title;
|
||||
if (chat.title?.trim()) return chat.title;
|
||||
if (chat.is_saved) return "Saved Messages";
|
||||
if (chat.type === "private") return "Direct chat";
|
||||
if (chat.type === "group") return "Group";
|
||||
return "Channel";
|
||||
}
|
||||
|
||||
function privateChatStatusLabel(chat: { counterpart_is_online?: boolean | null; counterpart_last_seen_at?: string | null }): string {
|
||||
if (chat.counterpart_is_online) {
|
||||
return "online";
|
||||
}
|
||||
if (chat.counterpart_last_seen_at) {
|
||||
return `last seen ${formatLastSeen(chat.counterpart_last_seen_at)}`;
|
||||
}
|
||||
return "offline";
|
||||
}
|
||||
|
||||
function initialsFromName(value: string): string {
|
||||
const prepared = value.trim();
|
||||
if (!prepared) {
|
||||
return "?";
|
||||
}
|
||||
const parts = prepared.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
||||
}
|
||||
return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
|
||||
Reference in New Issue
Block a user