feat(contacts): add contacts module with backend APIs and web tab
Some checks failed
CI / test (push) Failing after 18s
Some checks failed
CI / test (push) Failing after 18s
- add user_contacts table and migration - expose /users/contacts list/add/remove endpoints - add Contacts tab in chat list with add/remove actions
This commit is contained in:
@@ -38,3 +38,16 @@ export async function blockUser(userId: number): Promise<void> {
|
||||
export async function unblockUser(userId: number): Promise<void> {
|
||||
await http.delete(`/users/${userId}/block`);
|
||||
}
|
||||
|
||||
export async function listContacts(): Promise<UserSearchItem[]> {
|
||||
const { data } = await http.get<UserSearchItem[]>("/users/contacts");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addContact(userId: number): Promise<void> {
|
||||
await http.post(`/users/${userId}/contacts`);
|
||||
}
|
||||
|
||||
export async function removeContact(userId: number): Promise<void> {
|
||||
await http.delete(`/users/${userId}/contacts`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
|
||||
import { globalSearch } from "../api/search";
|
||||
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
|
||||
import { updateMyProfile } from "../api/users";
|
||||
import { addContact, listContacts, removeContact, updateMyProfile } from "../api/users";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { NewChatPanel } from "./NewChatPanel";
|
||||
@@ -23,8 +23,10 @@ export function ChatList() {
|
||||
const [messageResults, setMessageResults] = useState<Message[]>([]);
|
||||
const [archivedChats, setArchivedChats] = useState<typeof chats>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels" | "archived">("all");
|
||||
const [tab, setTab] = useState<"all" | "contacts" | "people" | "groups" | "channels" | "archived">("all");
|
||||
const [archivedLoading, setArchivedLoading] = useState(false);
|
||||
const [contactsLoading, setContactsLoading] = useState(false);
|
||||
const [contacts, setContacts] = useState<UserSearchItem[]>([]);
|
||||
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
|
||||
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
|
||||
@@ -74,6 +76,33 @@ export function ChatList() {
|
||||
};
|
||||
}, [tab, chats.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "contacts") {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setContactsLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const rows = await listContacts();
|
||||
if (!cancelled) {
|
||||
setContacts(rows);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setContacts([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setContactsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
const term = search.trim();
|
||||
if (term.replace("@", "").length < 2) {
|
||||
@@ -162,8 +191,9 @@ export function ChatList() {
|
||||
return true;
|
||||
});
|
||||
|
||||
const tabs: Array<{ id: "all" | "people" | "groups" | "channels" | "archived"; label: string }> = [
|
||||
const tabs: Array<{ id: "all" | "contacts" | "people" | "groups" | "channels" | "archived"; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "contacts", label: "Contacts" },
|
||||
{ id: "people", label: "Люди" },
|
||||
{ id: "groups", label: "Groups" },
|
||||
{ id: "channels", label: "Каналы" },
|
||||
@@ -242,7 +272,7 @@ export function ChatList() {
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { void openSavedMessages(); setMenuOpen(false); }}>
|
||||
Saved Messages
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setTab("people"); setMenuOpen(false); }}>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setTab("contacts"); setMenuOpen(false); }}>
|
||||
Contacts
|
||||
</button>
|
||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}>
|
||||
@@ -286,22 +316,36 @@ export function ChatList() {
|
||||
<div className="mb-1">
|
||||
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">People</p>
|
||||
{userResults.slice(0, 5).map((user) => (
|
||||
<button
|
||||
className="block w-full rounded-lg px-2 py-1.5 text-left hover:bg-slate-800"
|
||||
key={`user-${user.id}`}
|
||||
onClick={async () => {
|
||||
const chat = await createPrivateChat(user.id);
|
||||
const updatedChats = await getChats();
|
||||
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
|
||||
setSearch("");
|
||||
setUserResults([]);
|
||||
setDiscoverResults([]);
|
||||
setMessageResults([]);
|
||||
}}
|
||||
>
|
||||
<p className="truncate text-xs font-semibold">{user.name}</p>
|
||||
<p className="truncate text-[11px] text-slate-400">@{user.username}</p>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-800" key={`user-${user.id}`}>
|
||||
<button
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={async () => {
|
||||
const chat = await createPrivateChat(user.id);
|
||||
const updatedChats = await getChats();
|
||||
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
|
||||
setSearch("");
|
||||
setUserResults([]);
|
||||
setDiscoverResults([]);
|
||||
setMessageResults([]);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<p className="truncate text-xs font-semibold">{user.name}</p>
|
||||
<p className="truncate text-[11px] text-slate-400">@{user.username}</p>
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-slate-700 px-2 py-1 text-[10px] hover:bg-slate-600"
|
||||
onClick={async () => {
|
||||
await addContact(user.id);
|
||||
if (tab === "contacts") {
|
||||
setContacts(await listContacts());
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -388,15 +432,49 @@ export function ChatList() {
|
||||
{tab === "archived" && !archivedLoading && archivedChats.length === 0 ? (
|
||||
<p className="px-4 py-3 text-xs text-slate-400">Archive is empty</p>
|
||||
) : null}
|
||||
{tab === "contacts" && contactsLoading ? (
|
||||
<p className="px-4 py-3 text-xs text-slate-400">Loading contacts...</p>
|
||||
) : null}
|
||||
{tab === "contacts" && !contactsLoading && contacts.length === 0 ? (
|
||||
<p className="px-4 py-3 text-xs text-slate-400">No contacts yet</p>
|
||||
) : null}
|
||||
{tab === "contacts"
|
||||
? contacts.map((user) => (
|
||||
<div className="flex items-center gap-2 border-b border-slate-800/60 px-4 py-3" key={`contact-${user.id}`}>
|
||||
<button
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={async () => {
|
||||
const chat = await createPrivateChat(user.id);
|
||||
const updatedChats = await getChats();
|
||||
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<p className="truncate text-sm font-semibold">{user.name}</p>
|
||||
<p className="truncate text-xs text-slate-400">@{user.username}</p>
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-slate-700 px-2 py-1 text-[10px] text-red-200 hover:bg-slate-600"
|
||||
onClick={async () => {
|
||||
await removeContact(user.id);
|
||||
setContacts((prev) => prev.filter((item) => item.id !== user.id));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
|
||||
{pinnedVisibleChats.length > 0 ? (
|
||||
{tab !== "contacts" && pinnedVisibleChats.length > 0 ? (
|
||||
<p className="px-4 py-2 text-[10px] uppercase tracking-wide text-slate-400">Pinned</p>
|
||||
) : null}
|
||||
{pinnedVisibleChats.map(renderChatRow)}
|
||||
{regularVisibleChats.length > 0 ? (
|
||||
{tab !== "contacts" ? pinnedVisibleChats.map(renderChatRow) : null}
|
||||
{tab !== "contacts" && regularVisibleChats.length > 0 ? (
|
||||
<p className="px-4 py-2 text-[10px] uppercase tracking-wide text-slate-400">Chats</p>
|
||||
) : null}
|
||||
{regularVisibleChats.map(renderChatRow)}
|
||||
{tab !== "contacts" ? regularVisibleChats.map(renderChatRow) : null}
|
||||
</div>
|
||||
<NewChatPanel />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user