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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user