232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
import { FormEvent, useMemo, useState } from "react";
|
|
import { createChat, createPrivateChat, createPublicChat, getChats, getSavedMessagesChat, joinByInvite } from "../api/chats";
|
|
import { searchUsers } from "../api/users";
|
|
import type { ChatType, UserSearchItem } from "../chat/types";
|
|
import { useChatStore } from "../store/chatStore";
|
|
|
|
type CreateMode = "group" | "channel";
|
|
type DialogMode = "none" | "private" | "group" | "channel" | "invite";
|
|
|
|
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 [inviteToken, setInviteToken] = useState("");
|
|
const [isPublic, setIsPublic] = useState(false);
|
|
const [results, setResults] = useState<UserSearchItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
|
|
|
const normalizedQuery = useMemo(() => query.trim(), [query]);
|
|
|
|
async function handleSearchUsers(value: string) {
|
|
setQuery(value);
|
|
setError(null);
|
|
if (value.trim().replace("@", "").length < 2) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
try {
|
|
const users = await searchUsers(value);
|
|
setResults(users);
|
|
} catch {
|
|
setError("Search failed");
|
|
}
|
|
}
|
|
|
|
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 {
|
|
const chat = await createPrivateChat(userId);
|
|
await refreshChatsAndSelect(chat.id);
|
|
setDialog("none");
|
|
setQuery("");
|
|
setResults([]);
|
|
} catch {
|
|
setError("Failed to create private chat");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function createByType(event: FormEvent, mode: CreateMode) {
|
|
event.preventDefault();
|
|
if (!title.trim()) {
|
|
setError("Title is required");
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
let chat;
|
|
if (isPublic) {
|
|
const normalizedHandle = handle.trim().replace("@", "").toLowerCase();
|
|
if (!normalizedHandle) {
|
|
setError("Public chat requires @handle");
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
chat = await createPublicChat(mode, title.trim(), normalizedHandle, description.trim() || undefined);
|
|
} else {
|
|
chat = await createChat(mode as ChatType, title.trim(), []);
|
|
}
|
|
await refreshChatsAndSelect(chat.id);
|
|
setDialog("none");
|
|
setTitle("");
|
|
setHandle("");
|
|
setDescription("");
|
|
setIsPublic(false);
|
|
} catch {
|
|
setError("Failed to create chat");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function closeDialog() {
|
|
setDialog("none");
|
|
setError(null);
|
|
setQuery("");
|
|
setResults([]);
|
|
setIsPublic(false);
|
|
setInviteToken("");
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="absolute bottom-4 right-4 z-20">
|
|
<div className="relative">
|
|
{menuOpen ? (
|
|
<div className="absolute bottom-14 right-0 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("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); }}>
|
|
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); }}>
|
|
New Message
|
|
</button>
|
|
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("invite"); setMenuOpen(false); }}>
|
|
Join by Link
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<button className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-500 text-slate-950 shadow-lg hover:bg-sky-400" onClick={() => setMenuOpen((v) => !v)}>
|
|
<span className="block w-5 text-center text-2xl leading-none">{menuOpen ? "✕" : "+"}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{dialog !== "none" ? (
|
|
<div className="absolute inset-0 z-30 flex items-end justify-center bg-slate-950/55 p-3">
|
|
<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" : dialog === "channel" ? "New Channel" : "Join by Link"}
|
|
</p>
|
|
<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 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)}>
|
|
<p className="truncate font-semibold">{user.name}</p>
|
|
<p className="truncate text-xs text-slate-400">@{user.username}</p>
|
|
</button>
|
|
))}
|
|
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</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)} />
|
|
<label className="flex items-center gap-2 rounded-xl border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-sm">
|
|
<input checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} type="checkbox" />
|
|
Public {dialog} (discover + join by others)
|
|
</label>
|
|
{isPublic ? (
|
|
<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 (required for public)" value={handle} onChange={(e) => setHandle(e.target.value)} />
|
|
) : null}
|
|
<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}
|
|
|
|
{dialog === "invite" ? (
|
|
<form
|
|
className="space-y-2"
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
if (!inviteToken.trim()) {
|
|
setError("Invite token is required");
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const raw = inviteToken.trim();
|
|
const match = raw.match(/[?&]token=([^&]+)/i);
|
|
const token = match ? decodeURIComponent(match[1]) : raw;
|
|
const chat = await joinByInvite(token);
|
|
await refreshChatsAndSelect(chat.id);
|
|
closeDialog();
|
|
} catch {
|
|
setError("Failed to join by invite");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}}
|
|
>
|
|
<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="Invite link or token"
|
|
value={inviteToken}
|
|
onChange={(e) => setInviteToken(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">
|
|
Join
|
|
</button>
|
|
</form>
|
|
) : null}
|
|
|
|
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|