feat(messages): сообщения, реакции, пины, typing, read states и поиск

Бэкенд текстовой связи (AGENT.md 7.6, 7.15, 7.16, 8.6):

- POST/GET/PATCH/DELETE /channels/{id}/messages, ответы и упоминания,
  окно правки 24 часа, права автора или MANAGE_MESSAGES;
- реакции (PUT/DELETE .../reactions/{emoji}) с агрегацией и отметкой «моя»;
- закрепления (GET/PUT/DELETE .../pins) под MANAGE_MESSAGES;
- typing с лимитом 1/3 с и адресной рассылкой всем, кроме автора;
- read states: POST /channels/{id}/ack, синхронизация READ_STATE_UPDATE между
  устройствами пользователя;
- поиск FTS5 по комнате (10/мин) с экранированием запроса;
- лимиты отправки 5/5 с (burst 10) и slowmode комнаты; администратор инстанса
  обходит и то, и другое (AGENT.md 7.19);
- Gateway: DispatchToChannel/DispatchToChannelExcept доставляют события комнат
  только тем, кто видит комнату (VIEW_CHANNEL), права считает общий движок;
- store: messages, message_reactions, channel_read_states, files (загрузка
  файлов появится вместе с вложениями).

Тесты: жизненный цикл сообщения, видимость скрытой комнаты (404 участнику,
200 админу), slowmode, упоминания и ответы, typing и read state, фильтрация
событий комнаты между двумя WS-клиентами.
This commit is contained in:
2026-09-19 23:32:11 +03:00
parent 4550bea6cc
commit e4ffbb05a5
30 changed files with 3182 additions and 106 deletions
@@ -0,0 +1,95 @@
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { guildQueryKey, updateGuild } from '@/api/guilds';
import { myGuildsQueryKey } from '@/api/users';
import type { Guild } from '@/api/types';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, TextAreaField } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildGeneralSectionProps {
guild: Guild;
/** Владелец или участник с правами управления — иначе поля только для чтения. */
canEdit: boolean;
}
/** Общее в настройках сервера: имя и описание (PATCH /guilds/{id}). */
export function GuildGeneralSection({ guild, canEdit }: GuildGeneralSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState(guild.name);
const [description, setDescription] = useState(guild.description);
const [saved, setSaved] = useState(false);
const save = useMutation({
mutationFn: () =>
updateGuild(guild.id, { name: name.trim(), description: description.trim() }),
onSuccess: (updated) => {
queryClient.setQueryData(guildQueryKey(guild.id), updated);
// Рейка и сайдбар берут имя из снапшота — обновляем и его.
useSessionStore.getState().renameGuild(guild.id, updated.name);
void queryClient.invalidateQueries({ queryKey: guildQueryKey(guild.id) });
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
setSaved(true);
},
});
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
if (!canEdit || name.trim() === '') {
return;
}
setSaved(false);
save.mutate();
};
return (
<SettingsSection
title={t('settings.servers.general.title')}
description={t('settings.servers.general.description')}
>
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('settings.servers.general.name')}
name="guild_name"
value={name}
maxLength={64}
disabled={!canEdit}
onChange={(event) => setName(event.target.value)}
/>
<TextAreaField
label={t('settings.servers.general.guildDescription')}
placeholder={t('settings.servers.general.descriptionPlaceholder')}
name="guild_description"
rows={3}
maxLength={500}
value={description}
disabled={!canEdit}
onChange={(event) => setDescription(event.target.value)}
/>
{canEdit ? null : (
<p className="text-xs text-fg-muted">{t('settings.servers.readOnlyHint')}</p>
)}
{save.isError ? <ErrorNotice error={save.error} /> : null}
{saved && !save.isError ? (
<p role="status" className="text-sm text-success">
{t('settings.servers.general.saved')}
</p>
) : null}
{canEdit ? (
<div className="flex justify-end">
<Button type="submit" disabled={save.isPending || name.trim() === ''}>
{t(save.isPending ? 'common.saving' : 'common.save')}
</Button>
</div>
) : null}
</form>
</SettingsSection>
);
}