feat: add saved messages, public chat discovery/join, and chat delete options
All checks were successful
CI / test (push) Successful in 19s

- add Saved Messages system chat with dedicated API

- add public group/channel metadata and discover/join endpoints

- add chat delete flow with for_all option and channel-wide delete

- switch message actions to context menu and improve reply/forward visuals

- improve microphone permission handling for voice recording
This commit is contained in:
2026-03-08 00:41:35 +03:00
parent b5a7d733c6
commit b9f71b9528
12 changed files with 529 additions and 119 deletions

View File

@@ -1,5 +1,5 @@
import { http } from "./http";
import type { Chat, ChatType, Message, MessageType } from "../chat/types";
import type { Chat, ChatType, DiscoverChat, Message, MessageType } from "../chat/types";
import axios from "axios";
export async function getChats(query?: string): Promise<Chat[]> {
@@ -17,11 +17,24 @@ export async function createChat(type: ChatType, title: string | null, memberIds
const { data } = await http.post<Chat>("/chats", {
type,
title,
is_public: false,
member_ids: memberIds
});
return data;
}
export async function createPublicChat(type: "group" | "channel", title: string, handle: string, description?: string): Promise<Chat> {
const { data } = await http.post<Chat>("/chats", {
type,
title,
handle,
description,
is_public: true,
member_ids: []
});
return data;
}
export async function getMessages(chatId: number, beforeId?: number): Promise<Message[]> {
const { data } = await http.get<Message[]>(`/messages/${chatId}`, {
params: {
@@ -132,3 +145,24 @@ export async function pinMessage(chatId: number, messageId: number | null): Prom
});
return data;
}
export async function deleteChat(chatId: number, forAll: boolean): Promise<void> {
await http.delete(`/chats/${chatId}`, { params: { for_all: forAll } });
}
export async function discoverChats(query?: string): Promise<DiscoverChat[]> {
const { data } = await http.get<DiscoverChat[]>("/chats/discover", {
params: query?.trim() ? { query: query.trim() } : undefined
});
return data;
}
export async function joinChat(chatId: number): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/join`);
return data;
}
export async function getSavedMessagesChat(): Promise<Chat> {
const { data } = await http.get<Chat>("/chats/saved");
return data;
}

View File

@@ -6,10 +6,18 @@ export interface Chat {
id: number;
type: ChatType;
title: string | null;
handle?: string | null;
description?: string | null;
is_public?: boolean;
is_saved?: boolean;
pinned_message_id?: number | null;
created_at: string;
}
export interface DiscoverChat extends Chat {
is_member: boolean;
}
export interface Message {
id: number;
chat_id: number;

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { deleteChat } from "../api/chats";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { NewChatPanel } from "./NewChatPanel";
@@ -12,6 +13,10 @@ export function ChatList() {
const me = useAuthStore((s) => s.me);
const [search, setSearch] = useState("");
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
const [deleteForAll, setDeleteForAll] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
@@ -41,7 +46,7 @@ export function ChatList() {
];
return (
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60">
<aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
<div className="border-b border-slate-700/50 px-3 py-3">
<div className="mb-2 flex items-center gap-2">
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs"></button>
@@ -77,6 +82,11 @@ export function ChatList() {
}`}
key={chat.id}
onClick={() => setActiveChatId(chat.id)}
onContextMenu={(e) => {
e.preventDefault();
setCtxChatId(chat.id);
setCtxPos({ x: e.clientX, y: e.clientY });
}}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
@@ -96,6 +106,52 @@ export function ChatList() {
))}
</div>
<NewChatPanel />
{ctxChatId && ctxPos ? (
<div className="fixed z-50 w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" style={{ left: ctxPos.x, top: ctxPos.y }} onClick={(e) => e.stopPropagation()}>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={() => {
setDeleteModalChatId(ctxChatId);
setCtxChatId(null);
setCtxPos(null);
setDeleteForAll(false);
}}
>
Delete chat
</button>
</div>
) : null}
{deleteModalChatId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3">
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3">
<p className="mb-2 text-sm font-semibold">Delete chat #{deleteModalChatId}</p>
<label className="mb-3 flex items-center gap-2 text-sm">
<input checked={deleteForAll} onChange={(e) => setDeleteForAll(e.target.checked)} type="checkbox" />
Delete for everyone
</label>
<div className="flex gap-2">
<button
className="flex-1 rounded bg-red-500 px-3 py-2 text-sm font-semibold text-white"
onClick={async () => {
await deleteChat(deleteModalChatId, deleteForAll);
await loadChats(search.trim() ? search : undefined);
if (activeChatId === deleteModalChatId) {
setActiveChatId(null);
}
setDeleteModalChatId(null);
}}
>
Delete
</button>
<button className="flex-1 rounded bg-slate-700 px-3 py-2 text-sm" onClick={() => setDeleteModalChatId(null)}>
Cancel
</button>
</div>
</div>
</div>
) : null}
</aside>
);
}

View File

@@ -168,6 +168,13 @@ export function MessageComposer() {
async function startRecord() {
try {
if (navigator.permissions && navigator.permissions.query) {
const permission = await navigator.permissions.query({ name: "microphone" as PermissionName });
if (permission.state === "denied") {
setUploadError("Microphone access denied. Allow microphone in browser site permissions.");
return;
}
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
@@ -182,7 +189,7 @@ export function MessageComposer() {
recorder.start();
setIsRecording(true);
} catch {
setUploadError("Microphone access denied.");
setUploadError("Microphone access denied. Please allow microphone and retry.");
}
}

View File

@@ -1,9 +1,15 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { forwardMessage, pinMessage } from "../api/chats";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { formatTime } from "../utils/format";
type ContextMenuState = {
x: number;
y: number;
messageId: number;
} | null;
export function MessageList() {
const me = useAuthStore((s) => s.me);
const activeChatId = useChatStore((s) => s.activeChatId);
@@ -12,6 +18,7 @@ export function MessageList() {
const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const messages = useMemo(() => {
if (!activeChatId) {
@@ -19,6 +26,8 @@ export function MessageList() {
}
return messagesByChat[activeChatId] ?? [];
}, [activeChatId, messagesByChat]);
const messagesMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const activeChat = chats.find((chat) => chat.id === activeChatId);
if (!activeChatId) {
@@ -28,88 +37,100 @@ export function MessageList() {
async function handleForward(messageId: number) {
const targetRaw = window.prompt("Forward to chat id:");
if (!targetRaw) {
return;
}
if (!targetRaw) return;
const targetId = Number(targetRaw);
if (!Number.isFinite(targetId) || targetId <= 0) {
return;
}
if (!Number.isFinite(targetId) || targetId <= 0) return;
await forwardMessage(messageId, targetId);
setCtx(null);
}
async function handlePin(messageId: number) {
const nextPinned = activeChat?.pinned_message_id === messageId ? null : messageId;
const chat = await pinMessage(chatId, nextPinned);
updateChatPinnedMessage(chatId, chat.pinned_message_id ?? null);
setCtx(null);
}
return (
<div className="flex h-full flex-col">
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
{activeChat?.pinned_message_id ? (
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
Pinned message ID: {activeChat.pinned_message_id}
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || `Pinned message #${activeChat.pinned_message_id}`}
</div>
) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
{messages.map((message) => {
const own = message.sender_id === me?.id;
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
return (
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`} key={`${message.id}-${message.client_message_id ?? ""}`}>
<div
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
own
? "rounded-br-md bg-sky-500/90 text-slate-950"
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
}`}
onContextMenu={(e) => {
e.preventDefault();
setCtx({ x: e.clientX, y: e.clientY, messageId: message.id });
}}
>
{message.forwarded_from_message_id ? (
<p className={`mb-1 text-[11px] font-semibold ${own ? "text-slate-900/75" : "text-sky-300"}`}>Forwarded</p>
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-sky-300"}`}>
Forwarded message
</div>
) : null}
{message.reply_to_message_id ? (
<p className={`mb-1 text-[11px] ${own ? "text-slate-900/75" : "text-slate-300"}`}>Reply to #{message.reply_to_message_id}</p>
{replySource ? (
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-slate-300"}`}>
<p className="truncate font-semibold">{replySource.sender_id === me?.id ? "You" : "Reply"}</p>
<p className="truncate">{replySource.text || "[media]"}</p>
</div>
) : null}
{renderContent(message.type, message.text)}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="flex gap-1">
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => setReplyToMessage(chatId, message)}>
Reply
</button>
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handleForward(message.id)}>
Fwd
</button>
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handlePin(message.id)}>
Pin
</button>
</div>
<p className={`flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p>
</div>
</p>
</div>
</div>
);
})}
</div>
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
{ctx ? (
<div
className="fixed z-50 w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: ctx.x, top: ctx.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, msg);
}
setCtx(null);
}}
>
Reply
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handleForward(ctx.messageId)}>
Forward
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
</div>
) : null}
</div>
);
}
function renderContent(messageType: string, text: string | null) {
if (!text) {
return <p className="opacity-80">[empty]</p>;
}
if (messageType === "image") {
return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
}
if (messageType === "video" || messageType === "circle_video") {
return <video className="max-h-72 rounded-lg" controls src={text} />;
}
if (messageType === "audio" || messageType === "voice") {
return <audio controls src={text} />;
}
if (!text) return <p className="opacity-80">[empty]</p>;
if (messageType === "image") return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
if (messageType === "video" || messageType === "circle_video") return <video className="max-h-72 rounded-lg" controls src={text} />;
if (messageType === "audio" || messageType === "voice") return <audio controls src={text} />;
if (messageType === "file") {
return (
<a className="underline" href={text} rel="noreferrer" target="_blank">
@@ -121,14 +142,8 @@ function renderContent(messageType: string, text: string | null) {
}
function renderStatus(status: string | undefined): string {
if (status === "sending") {
return "";
}
if (status === "delivered") {
return "✓✓";
}
if (status === "read") {
return "✓✓";
}
if (status === "sending") return "⌛";
if (status === "delivered") return "✓✓";
if (status === "read") return "✓✓";
return "✓";
}

View File

@@ -1,17 +1,20 @@
import { FormEvent, useMemo, useState } from "react";
import { createChat, createPrivateChat, getChats } from "../api/chats";
import { createChat, createPrivateChat, createPublicChat, discoverChats, getChats, getSavedMessagesChat, joinChat } from "../api/chats";
import { searchUsers } from "../api/users";
import type { ChatType, UserSearchItem } from "../chat/types";
import type { ChatType, DiscoverChat, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore";
type CreateMode = "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel" | "discover";
export function NewChatPanel() {
const [dialog, setDialog] = useState<DialogMode>("none");
const [query, setQuery] = useState("");
const [title, setTitle] = useState("");
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
const [results, setResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -19,7 +22,7 @@ export function NewChatPanel() {
const normalizedQuery = useMemo(() => query.trim(), [query]);
async function handleSearch(value: string) {
async function handleSearchUsers(value: string) {
setQuery(value);
setError(null);
if (value.trim().replace("@", "").length < 2) {
@@ -34,20 +37,36 @@ export function NewChatPanel() {
}
}
async function refreshChatsAndSelectLast() {
async function handleDiscover(value: string) {
setQuery(value);
setError(null);
const items = await discoverChats(value.trim() ? value : undefined);
setDiscoverResults(items);
}
async function refreshChatsAndSelect(chatId?: number) {
const chats = await getChats();
useChatStore.setState({ chats });
if (chatId) {
setActiveChatId(chatId);
return;
}
if (chats[0]) {
setActiveChatId(chats[0].id);
}
}
async function openSavedMessages() {
const saved = await getSavedMessagesChat();
await refreshChatsAndSelect(saved.id);
}
async function createPrivate(userId: number) {
setLoading(true);
setError(null);
try {
await createPrivateChat(userId);
await refreshChatsAndSelectLast();
const chat = await createPrivateChat(userId);
await refreshChatsAndSelect(chat.id);
setDialog("none");
setQuery("");
setResults([]);
@@ -67,10 +86,17 @@ export function NewChatPanel() {
setLoading(true);
setError(null);
try {
await createChat(mode as ChatType, title.trim(), []);
await refreshChatsAndSelectLast();
let chat;
if (handle.trim()) {
chat = await createPublicChat(mode, title.trim(), handle.trim().replace("@", "").toLowerCase(), description.trim() || undefined);
} else {
chat = await createChat(mode as ChatType, title.trim(), []);
}
await refreshChatsAndSelect(chat.id);
setDialog("none");
setTitle("");
setHandle("");
setDescription("");
} catch {
setError("Failed to create chat");
} finally {
@@ -78,49 +104,51 @@ export function NewChatPanel() {
}
}
async function joinPublicChat(chatId: number) {
setLoading(true);
setError(null);
try {
const joined = await joinChat(chatId);
await refreshChatsAndSelect(joined.id);
setDialog("none");
} catch {
setError("Failed to join chat");
} finally {
setLoading(false);
}
}
function closeDialog() {
setDialog("none");
setError(null);
setQuery("");
setResults([]);
setDiscoverResults([]);
}
return (
<>
<div className="absolute bottom-4 right-4 z-20">
{menuOpen ? (
<div className="mb-2 w-44 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => {
setDialog("channel");
setMenuOpen(false);
}}
>
<div className="mb-2 w-48 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => void openSavedMessages()}>
Saved Messages
</button>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("discover"); setMenuOpen(false); }}>
Discover Chats
</button>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("channel"); setMenuOpen(false); }}>
New Channel
</button>
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => {
setDialog("group");
setMenuOpen(false);
}}
>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("group"); setMenuOpen(false); }}>
New Group
</button>
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => {
setDialog("private");
setMenuOpen(false);
}}
>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("private"); setMenuOpen(false); }}>
New Message
</button>
</div>
) : null}
<button
className="h-12 w-12 rounded-full bg-sky-500 text-xl font-semibold text-slate-950 shadow-lg hover:bg-sky-400"
onClick={() => setMenuOpen((v) => !v)}
>
<button className="h-12 w-12 rounded-full bg-sky-500 text-xl font-semibold text-slate-950 shadow-lg hover:bg-sky-400" onClick={() => setMenuOpen((v) => !v)}>
{menuOpen ? "×" : "+"}
</button>
</div>
@@ -130,47 +158,60 @@ export function NewChatPanel() {
<div className="w-full max-w-sm rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : "New Channel"}
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : dialog === "channel" ? "New Channel" : "Discover Chats"}
</p>
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>
Close
</button>
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>Close</button>
</div>
{dialog === "private" ? (
<div className="space-y-2">
<input
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="@username"
value={query}
onChange={(e) => void handleSearch(e.target.value)}
/>
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder="@username" value={query} onChange={(e) => void handleSearchUsers(e.target.value)} />
<div className="tg-scrollbar max-h-44 overflow-auto">
{results.map((user) => (
<button
className="mb-1 block w-full rounded-lg bg-slate-800 px-3 py-2 text-left text-sm hover:bg-slate-700"
key={user.id}
onClick={() => void createPrivate(user.id)}
>
<button className="mb-1 block w-full rounded-lg bg-slate-800 px-3 py-2 text-left text-sm hover:bg-slate-700" key={user.id} onClick={() => void createPrivate(user.id)}>
@{user.username}
</button>
))}
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
</div>
</div>
) : (
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog as CreateMode)}>
<input
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder={dialog === "group" ? "Group title" : "Channel title"}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
) : null}
{dialog === "discover" ? (
<div className="space-y-2">
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder="Search groups/channels by title or @handle" value={query} onChange={(e) => void handleDiscover(e.target.value)} />
<div className="tg-scrollbar max-h-52 overflow-auto">
{discoverResults.map((chat) => (
<div className="mb-1 flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2" key={chat.id}>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{chat.title || `${chat.type} #${chat.id}`}</p>
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
</div>
{chat.is_member ? (
<span className="text-xs text-slate-400">joined</span>
) : (
<button className="rounded bg-sky-500 px-2 py-1 text-xs font-semibold text-slate-950" onClick={() => void joinPublicChat(chat.id)}>
Join
</button>
)}
</div>
))}
{discoverResults.length === 0 ? <p className="text-xs text-slate-400">No public chats</p> : null}
</div>
</div>
) : null}
{dialog === "group" || dialog === "channel" ? (
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog)}>
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder={dialog === "group" ? "Group title" : "Channel title"} value={title} onChange={(e) => setTitle(e.target.value)} />
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder="@handle (optional, enables public join/search)" value={handle} onChange={(e) => setHandle(e.target.value)} />
<input className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<button className="w-full rounded-lg bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60" disabled={loading} type="submit">
Create {dialog}
</button>
</form>
)}
) : null}
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
</div>
</div>