Skip to content

Commit 5bfca92

Browse files
authored
Merge pull request #19 from AHTu21/feature/chat
feat: мессенджер Retrogen (чаты, вложения, группы)
2 parents 159a2db + 2689814 commit 5bfca92

45 files changed

Lines changed: 6368 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,18 @@
88

99
## [Unreleased]
1010

11-
_Черновик до следующего релиза; в окне «О программе» не показывается._
11+
### Сообщения (`/messages`)
12+
13+
- **Раздел «Сообщения»** в меню приложения: личные чаты, **группы**, системные каналы (**Новости** — только чтение, **Поддержка** — диалог с командой и **быстрыми командами**).
14+
- **Левая колонка:** иконка **профиля** (аватар, ФИО, «Создать группу», переход в **Избранное**), поиск пользователя для нового диалога, список чатов с непрочитанными; при открытом профиле список **приглушается**.
15+
- **Переписка:** отправка текста и **вложений** (скрепка, Enter и иконка отправки без обязательного текста), **эмодзи** в поле ввода; превью и **скачивание** файлов (иконка загрузки).
16+
- **Сообщения (ПКМ):** ответ, редактирование своих, **удалить у всех** / **только у меня**, пересылка в **Избранное**; скрытие «у себя» с запасом в `localStorage`, если миграция БД ещё не применена.
17+
- **Избранное:** личный канал для заметок и пересланных сообщений.
18+
- **Группы:** в шапке чата — **Настройки** (фото группы как у профиля — только «Загрузить фото», увеличение по клику), список участников, **добавление** по поиску, **Покинуть группу**, **Удалить группу** (только создатель); лента при открытых настройках приглушается.
19+
20+
### Интерфейс
21+
22+
- Пункт **«Сообщения»** в overflow-меню шапки (рядом с профилем и комнатами).
1223

1324
<!-- changelog:user -->
1425

@@ -195,6 +206,7 @@ _Черновик до следующего релиза; в окне «О пр
195206

196207
### API и сервер (история по релизам)
197208

209+
- **Unreleased / мессенджер:** Prisma `Chat`, `ChatMember`, `Message`, `MessageAttachment`, `MessageHidden`; модуль `server/src/chat/`; REST `/api/chats`, вложения `/api/chat/attachments/:id`, группа (`PATCH …/group/avatar`, `POST …/group/members`, `POST …/leave`, `DELETE …/:chatId`); Socket.IO `chat:join`, `chat:message.created|updated|hidden`, `chat:list.updated`, `chat:typing`. Файлы вложений — `server/data/chat-attachments/``.gitignore`). Миграции `20260525180000_messenger`, `20260525200000_message_hidden`; после pull — `npm run db:deploy`.
198210
- **0.10.0:** только клиент — `retrogen_profile_v1` в `localStorage`, событие `retrogen-profile`; модули `pages/profile/*`, `profileAccent`, `profileRoomColors`; миграция legacy (аватар ≠ обои доски).
199211
- **0.9.0:** миграция `Card.textDoc` (JSONB); сокет `stickerCollab:*` (Yjs relay in-memory).
200212
- **0.9.0 / ранее:** комната `listedInLobby`, `joinPasswordHash`, `POST /unlock`, `PATCH /access`; сокет **`planeLive`**`plane.preview`; прокси Vite к Socket.IO в dev.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@
1010
3. **Миграции:** из корня репозитория `npm run db:deploy` (или `npm run db:migrate` для интерактивной разработки).
1111
4. **Запуск:** `npm run dev` — клиент http://localhost:5173, API и WebSocket http://localhost:3000.
1212

13+
### Если localhost:5173 отдаёт 404
14+
15+
На Windows иногда на порту **5173** остаётся старый процесс Node (только IPv6 `[::1]`), а новый Vite слушает **127.0.0.1** — тогда `http://localhost:5173` не открывается, а `http://127.0.0.1:5173` работает.
16+
17+
Завершите лишний процесс на 5173 (Диспетчер задач → Node.js) или в PowerShell:
18+
19+
```powershell
20+
Get-NetTCPConnection -LocalPort 5173 -ErrorAction SilentlyContinue |
21+
Select-Object -ExpandProperty OwningProcess -Unique |
22+
ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }
23+
```
24+
25+
Затем снова `npm run dev`. API через прокси Vite: `http://localhost:5173/api/...`.
26+
1327
Переменные для сервера читаются из **`server/.env`** (подхватываются и Prisma CLI при запуске из каталога `server`, и самим приложением).
1428

1529
Если `psql` не в `PATH`, добавьте `bin` установки PostgreSQL в переменную окружения или подключайтесь через pgAdmin — на работу приложения это не влияет, нужна только корректная строка `DATABASE_URL`.
@@ -27,6 +41,8 @@
2741

2842
## Git и совместная разработка
2943

44+
Команда и приглашение в GitHub Collaborators: **[docs/CONTRIBUTORS.md](./docs/CONTRIBUTORS.md)**.
45+
3046
1. Клонируйте репозиторий, в корне: `npm install`.
3147
2. Скопируйте **`server/.env.example``server/.env`**, при необходимости **`client/.env.example``client/.env`** (файлы `.env` в git не попадают).
3248
3. `npm run db:deploy`, затем `npm run dev`.

client/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { RoomTeamPage } from "./pages/RoomTeamPage";
88
import { SummaryPage } from "./pages/SummaryPage";
99
import { WelcomePage } from "./pages/WelcomePage";
1010
import { WorkshopPage } from "./pages/WorkshopPage";
11+
import { MessagesPage } from "./pages/MessagesPage";
1112

1213
export default function App() {
1314
return (
@@ -18,6 +19,8 @@ export default function App() {
1819
<Route path="/register" element={<RegisterPage />} />
1920
<Route path="/profile" element={<ProfilePage />} />
2021
<Route path="/workshop" element={<WorkshopPage />} />
22+
<Route path="/messages" element={<MessagesPage />} />
23+
<Route path="/messages/:chatId" element={<MessagesPage />} />
2124
<Route path="/r/:slug/summary" element={<SummaryPage />} />
2225
<Route path="/r/:slug/team" element={<RoomTeamPage />} />
2326
<Route path="/r/:slug" element={<RoomPage />} />

client/src/api.ts

Lines changed: 286 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import type { LobbyRoomDto, RoomDto } from "./types";
2+
import type {
3+
ChatListItemDto,
4+
MessageDto,
5+
MessengerUserSearchDto,
6+
SupportQuickCommandDto,
7+
} from "./types/messenger";
28
import { getAccessToken, setAccessToken } from "./lib/authToken";
39
import { unlockHeadersForUrl } from "./lib/roomUnlockStorage";
410

511
export type AuthUserDto = { id: string; email: string; displayName: string; globalRole: string };
612

7-
async function apiFetch(input: string, init?: RequestInit): Promise<Response> {
13+
export async function apiFetch(input: string, init?: RequestInit): Promise<Response> {
814
const headers = new Headers(init?.headers);
915
const t = getAccessToken();
1016
if (t) headers.set("Authorization", `Bearer ${t}`);
@@ -429,6 +435,35 @@ export async function loginAccount(body: {
429435
return data;
430436
}
431437

438+
export async function updateAuthDisplayName(displayName: string): Promise<AuthUserDto> {
439+
const res = await apiFetch("/api/auth/me", {
440+
method: "PATCH",
441+
headers: { "Content-Type": "application/json" },
442+
body: JSON.stringify({ displayName }),
443+
});
444+
if (!res.ok) {
445+
const err = await res.json().catch(() => ({}));
446+
throw new Error((err as { error?: string }).error ?? res.statusText);
447+
}
448+
const data = (await res.json()) as { user: AuthUserDto };
449+
return data.user;
450+
}
451+
452+
export async function forwardMessageToSaved(
453+
chatId: string,
454+
messageId: string,
455+
): Promise<{ message: MessageDto; savedChatId: string }> {
456+
const res = await apiFetch(
457+
`/api/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/forward-to-saved`,
458+
{ method: "POST" },
459+
);
460+
if (!res.ok) {
461+
const err = await res.json().catch(() => ({}));
462+
throw new Error((err as { error?: string }).error ?? res.statusText);
463+
}
464+
return res.json() as Promise<{ message: MessageDto; savedChatId: string }>;
465+
}
466+
432467
export async function fetchAuthMe(): Promise<AuthUserDto | null> {
433468
const res = await apiFetch("/api/auth/me");
434469
if (res.status === 401) return null;
@@ -440,3 +475,253 @@ export async function fetchAuthMe(): Promise<AuthUserDto | null> {
440475
export function logoutAccount(): void {
441476
setAccessToken(null);
442477
}
478+
479+
export async function fetchChats(): Promise<{ chats: ChatListItemDto[] }> {
480+
const res = await apiFetch("/api/chats");
481+
if (!res.ok) {
482+
const err = await res.json().catch(() => ({}));
483+
throw new Error((err as { error?: string }).error ?? res.statusText);
484+
}
485+
return res.json() as Promise<{ chats: ChatListItemDto[] }>;
486+
}
487+
488+
export async function searchMessengerUsers(q: string): Promise<{ users: MessengerUserSearchDto[] }> {
489+
const sp = new URLSearchParams({ q });
490+
const res = await apiFetch(`/api/chats/users/search?${sp}`);
491+
if (!res.ok) {
492+
const err = await res.json().catch(() => ({}));
493+
throw new Error((err as { error?: string }).error ?? res.statusText);
494+
}
495+
return res.json() as Promise<{ users: MessengerUserSearchDto[] }>;
496+
}
497+
498+
export async function createDirectChat(userId: string): Promise<{ chat: ChatListItemDto }> {
499+
const res = await apiFetch("/api/chats/direct", {
500+
method: "POST",
501+
headers: { "Content-Type": "application/json" },
502+
body: JSON.stringify({ userId }),
503+
});
504+
if (!res.ok) {
505+
const err = await res.json().catch(() => ({}));
506+
throw new Error((err as { error?: string }).error ?? res.statusText);
507+
}
508+
return res.json() as Promise<{ chat: ChatListItemDto }>;
509+
}
510+
511+
export async function createGroupChat(title: string, memberIds: string[]): Promise<{ chat: ChatListItemDto }> {
512+
const res = await apiFetch("/api/chats/group", {
513+
method: "POST",
514+
headers: { "Content-Type": "application/json" },
515+
body: JSON.stringify({ title, memberIds }),
516+
});
517+
if (!res.ok) {
518+
const err = await res.json().catch(() => ({}));
519+
throw new Error((err as { error?: string }).error ?? res.statusText);
520+
}
521+
return res.json() as Promise<{ chat: ChatListItemDto }>;
522+
}
523+
524+
export async function fetchChatDetail(chatId: string): Promise<{ chat: ChatListItemDto }> {
525+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}`);
526+
if (!res.ok) {
527+
const err = await res.json().catch(() => ({}));
528+
throw new Error((err as { error?: string }).error ?? res.statusText);
529+
}
530+
return res.json() as Promise<{ chat: ChatListItemDto }>;
531+
}
532+
533+
export async function updateGroupChatAvatar(
534+
chatId: string,
535+
avatarUrl: string | null,
536+
): Promise<{ chat: ChatListItemDto }> {
537+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}/group/avatar`, {
538+
method: "PATCH",
539+
headers: { "Content-Type": "application/json" },
540+
body: JSON.stringify({ avatarUrl }),
541+
});
542+
if (!res.ok) {
543+
const err = await res.json().catch(() => ({}));
544+
throw new Error((err as { error?: string }).error ?? res.statusText);
545+
}
546+
return res.json() as Promise<{ chat: ChatListItemDto }>;
547+
}
548+
549+
export async function addGroupChatMembers(
550+
chatId: string,
551+
memberIds: string[],
552+
): Promise<{ chat: ChatListItemDto }> {
553+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}/group/members`, {
554+
method: "POST",
555+
headers: { "Content-Type": "application/json" },
556+
body: JSON.stringify({ memberIds }),
557+
});
558+
if (!res.ok) {
559+
const err = await res.json().catch(() => ({}));
560+
throw new Error((err as { error?: string }).error ?? res.statusText);
561+
}
562+
return res.json() as Promise<{ chat: ChatListItemDto }>;
563+
}
564+
565+
export async function leaveGroupChat(chatId: string): Promise<{ removed: boolean; chatDeleted: boolean }> {
566+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}/leave`, { method: "POST" });
567+
if (!res.ok) {
568+
const err = await res.json().catch(() => ({}));
569+
throw new Error((err as { error?: string }).error ?? res.statusText);
570+
}
571+
return res.json() as Promise<{ removed: boolean; chatDeleted: boolean }>;
572+
}
573+
574+
export async function deleteGroupChat(chatId: string): Promise<{ removed: boolean }> {
575+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}`, { method: "DELETE" });
576+
if (!res.ok) {
577+
const err = await res.json().catch(() => ({}));
578+
throw new Error((err as { error?: string }).error ?? res.statusText);
579+
}
580+
return res.json() as Promise<{ removed: boolean }>;
581+
}
582+
583+
export async function fetchChatMessages(
584+
chatId: string,
585+
opts?: { cursor?: string; limit?: number },
586+
): Promise<{ messages: MessageDto[]; nextCursor: string | null }> {
587+
const sp = new URLSearchParams();
588+
if (opts?.cursor) sp.set("cursor", opts.cursor);
589+
if (opts?.limit != null) sp.set("limit", String(opts.limit));
590+
const qs = sp.toString();
591+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}/messages${qs ? `?${qs}` : ""}`);
592+
if (!res.ok) {
593+
const err = await res.json().catch(() => ({}));
594+
throw new Error((err as { error?: string }).error ?? res.statusText);
595+
}
596+
return res.json() as Promise<{ messages: MessageDto[]; nextCursor: string | null }>;
597+
}
598+
599+
export async function sendChatMessage(
600+
chatId: string,
601+
body: { text: string; replyToMessageId?: string | null; clientMessageId?: string | null },
602+
): Promise<{ message: MessageDto }> {
603+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}/messages`, {
604+
method: "POST",
605+
headers: { "Content-Type": "application/json" },
606+
body: JSON.stringify(body),
607+
});
608+
if (!res.ok) {
609+
const err = await res.json().catch(() => ({}));
610+
throw new Error((err as { error?: string }).error ?? res.statusText);
611+
}
612+
return res.json() as Promise<{ message: MessageDto }>;
613+
}
614+
615+
export async function sendChatMessageWithAttachments(
616+
chatId: string,
617+
body: {
618+
text?: string;
619+
clientMessageId?: string | null;
620+
replyToMessageId?: string | null;
621+
files: File[];
622+
},
623+
): Promise<{ message: MessageDto }> {
624+
if (body.files.length === 0) {
625+
throw new Error("attachments_required");
626+
}
627+
const fd = new FormData();
628+
const trimmed = body.text?.trim() ?? "";
629+
if (trimmed) fd.set("text", trimmed);
630+
if (body.clientMessageId) fd.set("clientMessageId", body.clientMessageId);
631+
if (body.replyToMessageId) fd.set("replyToMessageId", body.replyToMessageId);
632+
for (const f of body.files) {
633+
fd.append("files", f, f.name);
634+
}
635+
const res = await apiFetch(
636+
`/api/chats/${encodeURIComponent(chatId)}/messages/upload`,
637+
{ method: "POST", body: fd },
638+
);
639+
if (!res.ok) {
640+
const err = await res.json().catch(() => ({}));
641+
throw new Error((err as { error?: string }).error ?? res.statusText);
642+
}
643+
return res.json() as Promise<{ message: MessageDto }>;
644+
}
645+
646+
export async function fetchSupportQuickCommands(): Promise<{ commands: SupportQuickCommandDto[] }> {
647+
const res = await apiFetch("/api/chats/support/quick-commands");
648+
if (!res.ok) {
649+
const err = await res.json().catch(() => ({}));
650+
throw new Error((err as { error?: string }).error ?? res.statusText);
651+
}
652+
return res.json() as Promise<{ commands: SupportQuickCommandDto[] }>;
653+
}
654+
655+
export async function runSupportQuickCommand(
656+
chatId: string,
657+
commandId: string,
658+
): Promise<{ userMessage: MessageDto; replyMessage: MessageDto }> {
659+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}/quick-command`, {
660+
method: "POST",
661+
headers: { "Content-Type": "application/json" },
662+
body: JSON.stringify({ commandId }),
663+
});
664+
if (!res.ok) {
665+
const err = await res.json().catch(() => ({}));
666+
throw new Error((err as { error?: string }).error ?? res.statusText);
667+
}
668+
return res.json() as Promise<{ userMessage: MessageDto; replyMessage: MessageDto }>;
669+
}
670+
671+
export async function editChatMessage(
672+
chatId: string,
673+
messageId: string,
674+
text: string,
675+
): Promise<{ message: MessageDto }> {
676+
const res = await apiFetch(
677+
`/api/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}`,
678+
{
679+
method: "PATCH",
680+
headers: { "Content-Type": "application/json" },
681+
body: JSON.stringify({ text }),
682+
},
683+
);
684+
if (!res.ok) {
685+
const err = await res.json().catch(() => ({}));
686+
throw new Error((err as { error?: string }).error ?? res.statusText);
687+
}
688+
return res.json() as Promise<{ message: MessageDto }>;
689+
}
690+
691+
export async function deleteChatMessage(
692+
chatId: string,
693+
messageId: string,
694+
scope: "everyone" | "me",
695+
): Promise<
696+
| { scope: "everyone"; message: MessageDto }
697+
| { scope: "me"; messageId: string }
698+
> {
699+
const res = await apiFetch(
700+
`/api/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/delete`,
701+
{
702+
method: "POST",
703+
headers: { "Content-Type": "application/json" },
704+
body: JSON.stringify({ scope }),
705+
},
706+
);
707+
if (!res.ok) {
708+
const err = await res.json().catch(() => ({}));
709+
throw new Error((err as { error?: string }).error ?? res.statusText);
710+
}
711+
return res.json() as Promise<
712+
| { scope: "everyone"; message: MessageDto }
713+
| { scope: "me"; messageId: string }
714+
>;
715+
}
716+
717+
export async function markChatRead(chatId: string, messageId: string): Promise<void> {
718+
const res = await apiFetch(`/api/chats/${encodeURIComponent(chatId)}/read`, {
719+
method: "POST",
720+
headers: { "Content-Type": "application/json" },
721+
body: JSON.stringify({ messageId }),
722+
});
723+
if (!res.ok) {
724+
const err = await res.json().catch(() => ({}));
725+
throw new Error((err as { error?: string }).error ?? res.statusText);
726+
}
727+
}

client/src/components/RetrogenOverflowMenu.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ export function RetrogenOverflowMenu({
8787
<Link to="/workshop" role="menuitem" className={itemClass(isLight)} onClick={() => setOpen(false)}>
8888
Мастерская
8989
</Link>
90+
<Link to="/messages" role="menuitem" className={itemClass(isLight)} onClick={() => setOpen(false)}>
91+
Мессенджер
92+
</Link>
9093
{teamRoomSlug ? (
9194
<Link
9295
to={`/r/${teamRoomSlug}/team`}

0 commit comments

Comments
 (0)