feat: add saved messages, public chat discovery/join, and chat delete options
All checks were successful
CI / test (push) Successful in 19s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user