feat(web): базовый клиент Фазы 1 — вход, серверы, комнаты, настройки

Клиент React 19 + TanStack Query + Zustand:

- вход (поле TOTP появляется на `auth.2fa_required`), регистрация с проверками
  и учётом `registration_enabled`, онбординг первого входа с возможностью
  пропустить, редирект `/` и страница `/status`;
- защита маршрутов: неавторизованных — на `/login`, без онбординга — на
  `/onboarding`;
- оболочка `/app`: рейка серверов, сайдбар категорий и комнат, шапка сервера,
  панель пользователя, модалки создания сервера и комнаты, экран «нет серверов»
  со вступлением в главный сервер, заглушка комнаты до Фазы 2;
- настройки: профиль, безопасность (step-up, сессии, logout-all, 2FA с
  локальным QR и кодами восстановления), внешний вид, админ-раздел инстанса;
- Gateway-клиент: HELLO/IDENTIFY/RESUME, heartbeat с ожиданием ACK,
  экспоненциальное переподключение, разбор `INVALID_SESSION {reason,resumable}`;
- диспетчер событий: READY/RESUMED/USER_UPDATE/GUILD_UPDATE/GUILD_DELETE/
  CHANNEL_* применяются к стору, GUILD_CREATE догружается по REST,
  MEMBER_*/ROLE_* инвалидируют запросы участников, ролей и карточки сервера;
- i18n ru/en, 66 тестов Vitest, `check`/`lint`/`test`/`build` зелёные.

Серверные правки под клиент:

- `totp_enabled` в профиле (`GET /users/@me`, вход, регистрация) — клиенту
  нужно знать, требовать ли код при step-up;
- `POST /auth/2fa/setup` отдаёт `otpauth_url` и `qr_png` (data-URI): QR-код
  рисуется локально, внешние сервисы генерации QR не используются;
- права в ответах — имена (`VIEW_CHANNEL|SEND_MESSAGES`), клиент сверяет имена.
This commit is contained in:
2026-09-19 22:03:46 +03:00
parent d6ca8e420f
commit 9c001fedfe
28 changed files with 1827 additions and 217 deletions
+10 -2
View File
@@ -54,8 +54,16 @@ export function stepUp(password: string, totpCode?: string): Promise<{ ok: true
return request<{ ok: true }>('/auth/step-up', { method: 'POST', body });
}
export function setupTotp(): Promise<{ secret: string; otpauth_url: string }> {
return request<{ secret: string; otpauth_url: string }>('/auth/2fa/setup', { method: 'POST' });
/** Ответ настройки 2FA: секрет, otpauth-ссылка и QR-код (data-URI с сервера). */
export interface TotpSetup {
secret: string;
otpauth_url: string;
/** QR рисует сервер: внешние сервисы генерации QR не используются. */
qr_png?: string;
}
export function setupTotp(): Promise<TotpSetup> {
return request<TotpSetup>('/auth/2fa/setup', { method: 'POST' });
}
export async function enableTotp(code: string): Promise<string[]> {
+17 -55
View File
@@ -1,3 +1,4 @@
import { parseChannelPayload } from './gatewayEvents';
import { describeUnknown } from '@/lib/http';
import type { Channel, Role, UserStatus } from './types';
@@ -20,9 +21,10 @@ export const GatewayOp = {
IDENTIFY: 2,
RESUME: 3,
INVALID_SESSION: 4,
/** RECONNECT по AGENT.md §8.3 — opcode 7. */
RECONNECT: 7,
HELLO: 10,
HEARTBEAT_ACK: 11,
RECONNECT: 12,
} as const;
export const GATEWAY_CLOSE_INVALID_SESSION = 4000;
@@ -142,54 +144,6 @@ function asStringArray(value: unknown): string[] {
: [];
}
function parseChannel(value: unknown): Channel | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
const type = asString(record['type']);
if (id === undefined || name === undefined) {
return null;
}
if (type !== 'text' && type !== 'voice' && type !== 'category') {
return null;
}
const channel: Channel = {
id,
name,
type,
position: asNumber(record['position']) ?? 0,
};
const guildId = asString(record['guild_id']);
const parentId = asString(record['parent_id']);
const userLimit = asNumber(record['user_limit']);
const slowmode = asNumber(record['slowmode_seconds']);
if (guildId !== undefined) {
channel.guild_id = guildId;
}
if (parentId !== undefined) {
channel.parent_id = parentId;
}
if (userLimit !== undefined) {
channel.user_limit = userLimit;
}
if (slowmode !== undefined) {
channel.slowmode_seconds = slowmode;
}
if (typeof record['can_send'] === 'boolean') {
channel.can_send = record['can_send'];
}
if (typeof record['can_connect'] === 'boolean') {
channel.can_connect = record['can_connect'];
}
if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view'];
}
return channel;
}
function parseRole(value: unknown): Role | null {
const record = asRecord(value);
if (record === null) {
@@ -265,7 +219,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
return null;
}
const channels = Array.isArray(record['channels'])
? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null)
? record['channels'].map(parseChannelPayload).filter((item): item is Channel => item !== null)
: [];
const roles = Array.isArray(record['roles'])
? record['roles'].map(parseRole).filter((item): item is Role => item !== null)
@@ -306,7 +260,9 @@ export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null {
? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null)
: [];
const dmChannels = Array.isArray(record['dm_channels'])
? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null)
? record['dm_channels']
.map(parseChannelPayload)
.filter((item): item is Channel => item !== null)
: [];
return {
user,
@@ -476,7 +432,8 @@ export class GatewayConnection {
}
this.attempt += 1;
const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
const jitter = Math.floor(Math.random() * 250);
// Джиттер пропорционален задержке: при базовых 5 мс в тестах он не «съедает» ожидание.
const jitter = Math.floor(Math.random() * Math.min(250, Math.max(1, backoff / 4)));
this.setStatus('reconnecting');
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
@@ -554,10 +511,15 @@ export class GatewayConnection {
return;
}
case GatewayOp.INVALID_SESSION: {
const resumable = parsed.d === true;
// Payload: { reason, resumable }. resumable=true (промах RESUME) —
// сессия жива, сервер сразу пришлёт полный READY; разлогинивать нельзя.
const data = asRecord(parsed.d);
const resumable = data?.['resumable'] === true;
if (resumable) {
this.logger.warn('gateway: invalid session, retrying resume');
this.dropSocket();
this.logger.warn('gateway: invalid session, waiting for full READY');
this.sessionId = null;
this.lastSeq = 0;
this.setStatus('connecting');
return;
}
this.sessionId = null;
+229
View File
@@ -0,0 +1,229 @@
import { userStatuses, type Channel, type UserStatus } from './types';
import type { GatewayUser } from './gateway';
/**
* Разбор payload'ов событий шлюза (AGENT.md §8.3).
*
* Все функции возвращают `null`, если кадр не соответствует контракту:
* неизвестные и лишние поля не ломают диспетчер, а событие просто
* игнорируется.
*/
export interface UserUpdateEvent {
user: GatewayUser;
}
export interface GuildUpdateEvent {
guild_id: string;
name?: string;
description?: string;
}
export interface GuildDeleteEvent {
guild_id: string;
}
export interface GuildCreateEvent {
guild_id: string;
}
export interface ChannelDeleteEvent {
channel_id: string;
guild_id: string;
}
export interface MemberUpdateEvent {
guild_id: string;
user_id: string;
role_ids?: string[];
role_id?: string;
role_added?: boolean;
}
export interface MemberRemoveEvent {
guild_id: string;
user_id: string;
}
export interface RoleEvent {
guild_id: string;
role_id: string;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
function asStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: undefined;
}
/** Пользователь в объёме `USER_UPDATE` (совместим со снапшотом READY). */
export function parseGatewayUserPayload(value: unknown): GatewayUser | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const username = asString(record['username']);
if (id === undefined || username === undefined) {
return null;
}
const user: GatewayUser = {
id,
username,
display_name: asString(record['display_name']) ?? username,
is_instance_admin: record['is_instance_admin'] === true,
badges: asStringArray(record['badges']) ?? [],
};
const avatar = asString(record['avatar_file_id']);
const status = asString(record['status']);
const customStatus = asString(record['custom_status']);
const customEmoji = asString(record['custom_status_emoji']);
if (avatar !== undefined) {
user.avatar_file_id = avatar;
}
if (userStatuses.includes(status as UserStatus)) {
user.status = status as UserStatus;
}
if (customStatus !== undefined) {
user.custom_status = customStatus;
}
if (customEmoji !== undefined) {
user.custom_status_emoji = customEmoji;
}
return user;
}
/** Деструктурирует `d` у событий, где payload вложен в `user`. */
export function parseUserUpdate(value: unknown): UserUpdateEvent | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const user = parseGatewayUserPayload(record['user'] ?? value);
return user === null ? null : { user };
}
export function parseGuildUpdate(value: unknown): GuildUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
if (record === null || guildId === undefined) {
return null;
}
const event: GuildUpdateEvent = { guild_id: guildId };
const name = asString(record['name']);
const description = asString(record['description']);
if (name !== undefined) {
event.name = name;
}
if (description !== undefined) {
event.description = description;
}
return event;
}
export function parseGuildId(value: unknown): string | null {
const record = asRecord(value);
return asString(record?.['guild_id']) ?? null;
}
export function parseChannelDelete(value: unknown): ChannelDeleteEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const guildId = asString(record?.['guild_id']);
if (channelId === undefined || guildId === undefined) {
return null;
}
return { channel_id: channelId, guild_id: guildId };
}
export function parseMemberUpdate(value: unknown): MemberUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
const userId = asString(record?.['user_id']);
if (record === null || guildId === undefined || userId === undefined) {
return null;
}
const event: MemberUpdateEvent = { guild_id: guildId, user_id: userId };
const roleIds = asStringArray(record['role_ids']);
const roleId = asString(record['role_id']);
if (roleIds !== undefined) {
event.role_ids = roleIds;
}
if (roleId !== undefined) {
event.role_id = roleId;
}
if (typeof record['role_added'] === 'boolean') {
event.role_added = record['role_added'];
}
return event;
}
export function parseMemberRemove(value: unknown): MemberRemoveEvent | null {
const event = parseMemberUpdate(value);
return event === null ? null : { guild_id: event.guild_id, user_id: event.user_id };
}
export function parseRoleEvent(value: unknown): RoleEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
const roleId = asString(record?.['role_id']);
if (record === null || guildId === undefined || roleId === undefined) {
return null;
}
return { guild_id: guildId, role_id: roleId };
}
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
export function parseChannelPayload(value: unknown): Channel | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
const type = asString(record['type']);
if (id === undefined || name === undefined) {
return null;
}
if (type !== 'text' && type !== 'voice' && type !== 'category') {
return null;
}
const channel: Channel = {
id,
name,
type,
position: typeof record['position'] === 'number' ? record['position'] : 0,
};
const guildId = asString(record['guild_id']);
const parentId = asString(record['parent_id']);
if (guildId !== undefined) {
channel.guild_id = guildId;
}
if (parentId !== undefined) {
channel.parent_id = parentId;
}
if (typeof record['user_limit'] === 'number') {
channel.user_limit = record['user_limit'];
}
if (typeof record['slowmode_seconds'] === 'number') {
channel.slowmode_seconds = record['slowmode_seconds'];
}
if (typeof record['can_send'] === 'boolean') {
channel.can_send = record['can_send'];
}
if (typeof record['can_connect'] === 'boolean') {
channel.can_connect = record['can_connect'];
}
if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view'];
}
return channel;
}
+3 -1
View File
@@ -26,6 +26,8 @@ export interface User {
badges: string[];
locale: string;
onboarding_completed: boolean;
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
totp_enabled: boolean;
}
/** Урезанное представление пользователя (участники сервера, READY). */
@@ -52,7 +54,7 @@ export interface Role {
name: string;
color: number;
position: number;
/** Битовая маска прав (строка — чтобы не терять старшие биты в JSON). */
/** Имена прав через `|`, как их отдаёт сервер (например `VIEW_CHANNEL|SEND_MESSAGES`). */
permissions: string;
is_default: boolean;
hoist: boolean;
+2 -1
View File
@@ -161,7 +161,8 @@
"namePlaceholder": "For example, “Friends”",
"hint": "You can rename it later in the server settings.",
"submit": "Create",
"limit": "You can create at most {{count}} servers on this instance."
"limit": "You can create at most {{count}} servers on this instance.",
"limitReached": "You have reached the server limit — remove one of your servers first."
},
"empty": {
"title": "No servers yet",
+4 -2
View File
@@ -161,7 +161,8 @@
"namePlaceholder": "Например, «Друзья»",
"hint": "Название можно изменить позже в настройках сервера.",
"submit": "Создать",
"limit": "На инстансе можно создать не более {{count}} серверов."
"limit": "На инстансе можно создать не более {{count}} серверов.",
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих."
},
"empty": {
"title": "Пока нет ни одного сервера",
@@ -327,7 +328,8 @@
"usersEmpty": "Пользователей нет.",
"audit": "Журнал аудита (последние {{count}})",
"auditEmpty": "Записей нет.",
"memberCount": "{{count}} участн.", "adminBadge": "админ",
"memberCount": "{{count}} участн.",
"adminBadge": "админ",
"mainBadge": "главный"
}
}
+10 -26
View File
@@ -32,33 +32,17 @@ export function avatarUrl(fileId: string | undefined): string | null {
return fileId === undefined || fileId === '' ? null : `/files/${encodeURIComponent(fileId)}`;
}
/** Разбирает число прав из строковой битовой маски, не падая на мусоре. */
export function parseBigInt(value: string | number | undefined): bigint {
if (value === undefined) {
return 0n;
}
try {
return BigInt(value);
} catch {
return 0n;
}
}
/**
* Права приходят с сервера списком имён (`permissions.Names` в Go):
* например `["VIEW_GUILD", "MANAGE_CHANNELS"]`. Проверяем вхождение имени.
*/
export const PERMISSION_ADMINISTRATOR = 'ADMINISTRATOR';
export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
/** Бит ADMINISTRATOR в маске прав (совпадает с серверной константой). */
export const PERMISSION_ADMINISTRATOR = 1n << 3n;
export const PERMISSION_MANAGE_GUILD = 1n << 5n;
export const PERMISSION_MANAGE_CHANNELS = 1n << 6n;
export const PERMISSION_OWNER = 1n << 62n;
/** Проверяет право в списке масок прав текущего пользователя. */
export function hasPermission(permissions: readonly string[], mask: bigint): boolean {
for (const value of permissions) {
const parsed = parseBigInt(value);
if (parsed === PERMISSION_OWNER || (parsed & mask) !== 0n) {
return true;
}
}
return false;
/** Есть ли у пользователя право с указанным именем. */
export function hasPermission(permissions: readonly string[], name: string): boolean {
return permissions.includes(name);
}
/** Может ли пользователь управлять сервером (создавать комнаты и т.п.). */
+26 -6
View File
@@ -6,24 +6,26 @@ import { Outlet, useLocation, useNavigate } from 'react-router';
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { instanceQueryKey } from '@/api/instance';
import { GatewayBridge } from '@/components/GatewayBridge';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useCurrentUser, useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
import { GuildRail } from '@/pages/app/GuildRail';
import { UserPanel } from '@/pages/app/UserPanel';
import { errorCode } from '@/lib/format';
/**
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
* пользователя. Данные серверов приходят из снапшота шлюза, а если шлюз ещё
* не подключился — подстраховываемся REST-запросом `GET /users/@me/guilds`.
* пользователя. Данные серверов приходят из снапшота шлюза (соединение
* поднимает `GatewayBridge`), а пока шлюз не подключился — подстраховываемся
* REST-запросом `GET /users/@me/guilds`.
*/
export default function AppLayout() {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -52,6 +54,21 @@ export default function AppLayout() {
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
const realGuildId = guildId === 'empty' ? null : guildId;
// Сервер появился (вступили в главный или создали свой) — сразу открываем его.
useEffect(() => {
if (realGuildId !== null || guilds.length === 0) {
return;
}
const first = guilds[0];
if (first === undefined) {
return;
}
const channel = useSessionStore.getState().firstChannelId(first.id);
void navigate(channel === null ? `/app/${first.id}` : `/app/${first.id}/${channel}`, {
replace: true,
});
}, [realGuildId, guilds, navigate]);
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
queryFn: ({ signal }) => fetchMyGuilds(signal),
@@ -129,6 +146,7 @@ export default function AppLayout() {
>
{t('app.skipToContent')}
</a>
<GatewayBridge userId={currentUser.data?.id ?? null} />
<div className="flex min-h-0 flex-1">
<GuildRail
guilds={guildList}
@@ -185,8 +203,10 @@ export default function AppLayout() {
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
</p>
)}
{limitReached && errorCode(create.error) === null ? (
<p className="text-xs text-warning">{t('errors.auth.guild_limit_reached')}</p>
{limitReached && !create.isError ? (
<p className="text-xs text-warning" role="status">
{t('guilds.create.limitReached')}
</p>
) : null}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
+1
View File
@@ -140,6 +140,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
<NavLink
to={`/app/${guildId}/${channel.id}`}
aria-current={channel.id === channelId ? 'page' : undefined}
aria-label={t('channels.select', { name: channel.name })}
title={channel.name}
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
channel.id === channelId
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currentUserQueryKey, fetchCurrentUser } from '@/api/users';
import { PasswordSection } from '@/pages/settings/PasswordSection';
import { SessionsSection } from '@/pages/settings/SessionsSection';
import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
@@ -9,13 +10,16 @@ import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
* Безопасность: смена пароля со step-up, список сессий с выходом на всех
* устройствах и включение 2FA с показом секрета и кодов восстановления.
*
* Контракт Фазы 1 не отдаёт флаг включённой 2FA в `GET /users/@me`, поэтому
* состояние «2FA включена» ведём локально: после успешного `2fa/enable`
* формы запрашивают код подтверждения.
* Состояние 2FA берём из профиля (`totp_enabled`), обновляя запрос после
* включения: от него зависит, требовать ли код при step-up.
*/
export default function SecuritySettingsPage() {
const { t } = useTranslation();
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const currentUser = useQuery({
queryKey: currentUserQueryKey,
queryFn: ({ signal }) => fetchCurrentUser(signal),
});
const twoFactorEnabled = currentUser.data?.totp_enabled === true;
return (
<div className="flex flex-col gap-5">
@@ -25,7 +29,12 @@ export default function SecuritySettingsPage() {
</header>
<PasswordSection twoFactorEnabled={twoFactorEnabled} />
<SessionsSection />
<TwoFactorSection enabled={twoFactorEnabled} onEnabled={() => setTwoFactorEnabled(true)} />
<TwoFactorSection
enabled={twoFactorEnabled}
onEnabled={() => {
void currentUser.refetch();
}}
/>
</div>
);
}
+4 -4
View File
@@ -21,6 +21,7 @@ export function TwoFactorSection({
const { t } = useTranslation();
const [secret, setSecret] = useState<string | null>(null);
const [otpauthUrl, setOtpauthUrl] = useState('');
const [qrPng, setQrPng] = useState('');
const [code, setCode] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [qrFailed, setQrFailed] = useState(false);
@@ -31,6 +32,7 @@ export function TwoFactorSection({
onSuccess: (payload) => {
setSecret(payload.secret);
setOtpauthUrl(payload.otpauth_url);
setQrPng(payload.qr_png ?? '');
setRecoveryCodes(null);
setQrFailed(false);
},
@@ -81,15 +83,13 @@ export function TwoFactorSection({
<p className="text-sm text-fg-muted">{t('settings.security.twoFactorSetupHint')}</p>
<div className="flex flex-wrap items-start gap-4">
{qrFailed || otpauthUrl === '' ? (
{qrFailed || qrPng === '' ? (
<p className="text-xs text-fg-muted">
{t('settings.security.twoFactorQrUnavailable')}
</p>
) : (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(
otpauthUrl,
)}`}
src={qrPng}
alt={t('settings.security.twoFactorQrAlt')}
width={160}
height={160}
+148 -6
View File
@@ -8,6 +8,14 @@ import {
type GatewayStatus,
type GatewayUser,
} from '@/api/gateway';
import {
parseChannelDelete,
parseChannelPayload,
parseGuildId,
parseGuildUpdate,
parseRoleEvent,
parseUserUpdate,
} from '@/api/gatewayEvents';
import { useSessionStore } from '@/stores/session';
/**
@@ -103,15 +111,81 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
useGatewayStore.setState({ status: 'connected', attempts: 0 });
return;
}
// Заготовка диспетчера Фазы 2: события приходят, но пока не меняют UI.
case 'GUILD_CREATE':
case 'GUILD_UPDATE':
case 'GUILD_DELETE':
case 'USER_UPDATE': {
// Профиль текущего пользователя обновился (например, с другого устройства).
const update = parseUserUpdate(event.d);
if (update !== null) {
session.applyUserUpdate(update.user);
}
return;
}
case 'GUILD_CREATE': {
// В событии только guild_id — перечитываем список серверов по REST.
const guildId = parseGuildId(event.d);
if (guildId !== null) {
void addGuildFromRest(guildId);
}
return;
}
case 'GUILD_UPDATE': {
const update = parseGuildUpdate(event.d);
if (update?.name !== undefined) {
session.renameGuild(update.guild_id, update.name);
}
return;
}
case 'GUILD_DELETE': {
const guildId = parseGuildId(event.d);
if (guildId === null) {
return;
}
// removeGuild сам выбирает первый оставшийся сервер (или пустой список).
session.removeGuild(guildId);
void invalidateGuildQueries(guildId);
syncUrlWithSelection();
return;
}
case 'CHANNEL_CREATE':
case 'CHANNEL_UPDATE':
case 'CHANNEL_DELETE':
case 'CHANNEL_UPDATE': {
const channel = parseChannelPayload(event.d);
if (channel === null) {
return;
}
const guildId = channel.guild_id ?? session.selectedGuildId;
if (guildId !== null) {
session.upsertChannel(guildId, channel);
}
return;
}
case 'CHANNEL_DELETE': {
const deleted = parseChannelDelete(event.d);
if (deleted === null) {
return;
}
session.removeChannel(deleted.guild_id, deleted.channel_id);
syncUrlWithSelection();
return;
}
case 'MEMBER_UPDATE':
case 'MEMBER_REMOVE': {
// Роли участника влияют на права: перечитываем участников и карточку сервера.
const guildId = parseGuildId(event.d);
if (guildId !== null) {
void invalidateMembershipQueries(guildId);
}
return;
}
case 'ROLE_CREATE':
case 'ROLE_UPDATE':
case 'ROLE_DELETE': {
const role = parseRoleEvent(event.d);
if (role !== null) {
void invalidateMembershipQueries(role.guild_id);
}
return;
}
case 'PRESENCE_UPDATE':
// Присутствие других участников в Фазе 1 не отображается.
return;
default:
// Неизвестные события игнорируем: контракт может расширяться.
@@ -119,6 +193,74 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
}
/** Сервер появился (создан или найден): забираем сводку из REST. */
async function addGuildFromRest(guildId: string): Promise<void> {
const [{ getQueryClient }, users] = await Promise.all([
import('@/lib/queryClient'),
import('@/api/users'),
]);
const client = getQueryClient();
try {
const guilds = await client.fetchQuery({
queryKey: users.myGuildsQueryKey,
queryFn: () => users.fetchMyGuilds(),
});
const guild = guilds.find((item) => item.id === guildId);
if (guild !== undefined) {
useSessionStore.getState().upsertGuild(guild);
}
} catch {
// Сеть подвела — просто помечаем данные устаревшими для следующего рендера.
await client.invalidateQueries({ queryKey: users.myGuildsQueryKey });
}
}
/** Инвалидация REST-данных сервера и участников после событий ролей. */
async function invalidateMembershipQueries(guildId: string): Promise<void> {
const [{ getQueryClient }, guilds] = await Promise.all([
import('@/lib/queryClient'),
import('@/api/guilds'),
]);
const client = getQueryClient();
await Promise.all([
client.invalidateQueries({ queryKey: guilds.guildMembersQueryKey(guildId) }),
client.invalidateQueries({ queryKey: guilds.guildRolesQueryKey(guildId) }),
client.invalidateQueries({ queryKey: guilds.guildQueryKey(guildId) }),
]);
}
/** Инвалидация данных удалённого сервера (карточка, участники, каналы). */
async function invalidateGuildQueries(guildId: string): Promise<void> {
const [{ getQueryClient }, guilds] = await Promise.all([
import('@/lib/queryClient'),
import('@/api/guilds'),
]);
const client = getQueryClient();
await Promise.all([
client.invalidateQueries({ queryKey: guilds.guildQueryKey(guildId) }),
client.invalidateQueries({ queryKey: guilds.guildChannelsQueryKey(guildId) }),
client.invalidateQueries({ queryKey: guilds.guildMembersQueryKey(guildId) }),
]);
}
/** Держит URL в согласии с выбранным сервером/комнатой после удалений. */
function syncUrlWithSelection(): void {
if (typeof window === 'undefined') {
return;
}
const state = useSessionStore.getState();
const target =
state.selectedGuildId === null
? '/app/empty'
: state.selectedChannelId === null
? `/app/${state.selectedGuildId}`
: `/app/${state.selectedGuildId}/${state.selectedChannelId}`;
if (window.location.pathname !== target) {
window.history.replaceState(null, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}
}
function parseResumedUser(payload: unknown): GatewayUser | null {
if (typeof payload !== 'object' || payload === null) {
return null;
+62 -3
View File
@@ -29,10 +29,18 @@ interface SessionState {
setUser: (user: GatewayUser | null) => void;
/** Синхронизация с REST-профилем (PATCH /users/@me возвращает полного user). */
patchUser: (user: User) => void;
/** USER_UPDATE: обновляет только текущего пользователя. */
applyUserUpdate: (user: GatewayUser) => void;
upsertGuild: (guild: GuildSummary) => void;
/** GUILD_UPDATE: имя сервера (описание в снапшоте Фазы 1 не хранится). */
renameGuild: (guildId: string, name: string) => void;
removeGuild: (guildId: string) => void;
setGuildChannels: (guildId: string, channels: Channel[]) => void;
upsertChannel: (guildId: string, channel: Channel) => void;
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
removeChannel: (guildId: string, channelId: string) => void;
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */
firstChannelId: (guildId: string) => string | null;
selectGuild: (guildId: string | null) => void;
selectChannel: (guildId: string | null, channelId: string | null) => void;
reset: () => void;
@@ -137,6 +145,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
set({ user: next });
},
applyUserUpdate: (user) => {
const current = get().user;
if (current === null || current.id !== user.id) {
// Профиль другого участника в Фазе 1 негде показывать.
return;
}
set({ user });
},
upsertGuild: (guild) => {
const guilds = get().guilds;
const index = guilds.findIndex((item) => item.id === guild.id);
@@ -154,12 +171,26 @@ export const useSessionStore = create<SessionState>((set, get) => ({
set({ guilds: next });
},
renameGuild: (guildId, name) => {
set({
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, name } : guild)),
});
},
removeGuild: (guildId) => {
const state = get();
const guilds = state.guilds.filter((guild) => guild.id !== guildId);
const selectionLost = state.selectedGuildId === guildId;
// Если удалили выбранный сервер — выбираем первый оставшийся.
const nextGuild = selectionLost ? (guilds[0] ?? null) : null;
set({
guilds: state.guilds.filter((guild) => guild.id !== guildId),
selectedGuildId: state.selectedGuildId === guildId ? null : state.selectedGuildId,
selectedChannelId: state.selectedGuildId === guildId ? null : state.selectedChannelId,
guilds,
selectedGuildId: selectionLost ? (nextGuild?.id ?? null) : state.selectedGuildId,
selectedChannelId: selectionLost
? nextGuild === null
? null
: firstVisibleChannelId(nextGuild)
: state.selectedChannelId,
});
},
@@ -186,6 +217,24 @@ export const useSessionStore = create<SessionState>((set, get) => ({
});
},
removeChannel: (guildId, channelId) => {
const state = get();
const guild = state.guilds.find((item) => item.id === guildId);
const channels = (guild?.channels ?? []).filter((channel) => channel.id !== channelId);
const selectionLost = state.selectedChannelId === channelId;
set({
guilds: state.guilds.map((item) => (item.id === guildId ? { ...item, channels } : item)),
selectedChannelId: selectionLost
? firstVisibleChannelId({ channels })
: state.selectedChannelId,
});
},
firstChannelId: (guildId) => {
const guild = get().guilds.find((item) => item.id === guildId);
return guild === undefined ? null : firstVisibleChannelId(guild);
},
selectGuild: (guildId) => {
set({ selectedGuildId: guildId, selectedChannelId: null });
},
@@ -199,6 +248,16 @@ export const useSessionStore = create<SessionState>((set, get) => ({
},
}));
/** Первая видимая текстовая (или любая) комната сервера. */
function firstVisibleChannelId(guild: { channels: Channel[] }): string | null {
const visible = guild.channels
.filter((channel) => channel.type !== 'category' && channel.can_view !== false)
.slice()
.sort((left, right) => left.position - right.position);
const text = visible.find((channel) => channel.type === 'text') ?? visible[0];
return text?.id ?? null;
}
/** Селектор сервера по id (без подписки на весь список). */
export function selectGuildById(guildId: string | null) {
return (state: SessionState): SessionGuild | null =>