feat(messages): счётчики упоминаний, квота приглашений и read state автора
- упоминания в сообщении увеличивают счётчик упоминаний комнаты у упомянутых (store.BumpMentionCount) и отправляют им READ_STATE_UPDATE на все устройства; - отправка сообщения отмечает его прочитанным для автора; - подтверждение прочтения сбрасывает упоминания; - суточная квота 10 приглашений на пользователя (AGENT.md 8.6), администратор инстанса её обходит; httpx.NewRateLimiterWindow для произвольных окон; - тест на рост и сброс счётчика упоминаний.
This commit is contained in:
@@ -93,8 +93,9 @@ export function Menu({ open, label, onClose, children, anchorRef, className = ''
|
||||
|
||||
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Tab') {
|
||||
// Меню — не часть tab-порядка: уводим фокус дальше по странице.
|
||||
// Меню — не часть tab-порядка: закрываем его и оставляем фокус на якоре.
|
||||
onClose();
|
||||
anchorRef?.current?.focus();
|
||||
return;
|
||||
}
|
||||
const items = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? []);
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function LoginPage() {
|
||||
const registrationEnabled = instance.data?.registration_enabled === true;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||
<main className="gl-page mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.login.title')}</h1>
|
||||
<p className="mt-1 text-fg-muted">{t('auth.login.subtitle')}</p>
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function OnboardingPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
|
||||
<main className="gl-page mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{suggestedName === ''
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||
<main className="gl-page mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.register.title')}</h1>
|
||||
<p className="mt-1 text-fg-muted">
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function StatusPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
|
||||
<div className="gl-page mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('app.name')}</h1>
|
||||
|
||||
@@ -255,6 +255,20 @@ describe('настройки серверов', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
|
||||
});
|
||||
|
||||
it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => {
|
||||
installFetch(serversRoutes());
|
||||
|
||||
renderApp('/settings/servers/g-2');
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('покинуть сервер можно после подтверждения в модальном окне', async () => {
|
||||
const fetchMock = installFetch(
|
||||
serversRoutes([
|
||||
|
||||
@@ -112,4 +112,47 @@ describe('session store', () => {
|
||||
expect(state.guilds).toEqual([]);
|
||||
expect(state.selectedGuildId).toBeNull();
|
||||
});
|
||||
|
||||
it('хранит выбранный в настройках сервер отдельно от выбора в приложении', () => {
|
||||
act(() => {
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'READY',
|
||||
s: 6,
|
||||
d: makeReadySnapshot([
|
||||
{ id: 'g-1', name: 'Main' },
|
||||
{ id: 'g-2', name: 'Second' },
|
||||
]),
|
||||
});
|
||||
useSessionStore.getState().selectChannel('g-1', 'c-1');
|
||||
useSessionStore.getState().selectSettingsGuild('g-2');
|
||||
});
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
expect(state.settingsGuildId).toBe('g-2');
|
||||
// Выбор в приложении не пострадал.
|
||||
expect(state.selectedGuildId).toBe('g-1');
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().reset();
|
||||
});
|
||||
expect(useSessionStore.getState().settingsGuildId).toBeNull();
|
||||
});
|
||||
|
||||
it('обновляет свой никнейм на сервере', () => {
|
||||
act(() => {
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'READY',
|
||||
s: 7,
|
||||
d: makeReadySnapshot([{ id: 'g-1', name: 'Main', my_nickname: 'Аля' }]),
|
||||
});
|
||||
});
|
||||
expect(useSessionStore.getState().guilds[0]?.my_nickname).toBe('Аля');
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setMyNickname('g-1', 'Алиса');
|
||||
});
|
||||
expect(useSessionStore.getState().guilds[0]?.my_nickname).toBe('Алиса');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user