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:
@@ -29,6 +29,8 @@
|
||||
|
||||
--duration-fast: 120ms;
|
||||
--duration-base: 200ms;
|
||||
/* Шаг каскадного появления элементов списка (см. .gl-stagger). */
|
||||
--stagger-step: 28ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@@ -70,12 +72,237 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Анимации клиента (docs/client-web.md §3): сдержанные, быстрые, без
|
||||
* зависимостей. Ключевые кадры и классы живут здесь, а не в inline-стилях,
|
||||
* чтобы их можно было отключить одним media-запросом ниже.
|
||||
*
|
||||
* Все входные анимации используют fill-mode `backwards`: до старта элемент
|
||||
* уже в начальном кадре (это нужно каскаду с задержками), а после финиша
|
||||
* отдаёт управление обычным стилям — hover-трансформации продолжают работать.
|
||||
*/
|
||||
|
||||
/* Появление страницы при смене маршрута: fade + сдвиг на 6px. */
|
||||
@keyframes gl-page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.gl-page {
|
||||
animation: gl-page-in var(--duration-base) var(--ease-out) backwards;
|
||||
}
|
||||
|
||||
/* Затемнение подложки модального окна. */
|
||||
@keyframes gl-backdrop-in {
|
||||
from {
|
||||
background-color: rgb(0 0 0 / 0);
|
||||
}
|
||||
to {
|
||||
background-color: rgb(0 0 0 / 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gl-backdrop-out {
|
||||
from {
|
||||
background-color: rgb(0 0 0 / 0.6);
|
||||
}
|
||||
to {
|
||||
background-color: rgb(0 0 0 / 0);
|
||||
}
|
||||
}
|
||||
|
||||
.gl-backdrop-in {
|
||||
animation: gl-backdrop-in var(--duration-fast) var(--ease-out) backwards;
|
||||
}
|
||||
|
||||
.gl-backdrop-out {
|
||||
animation: gl-backdrop-out var(--duration-fast) var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
/* Панель модального окна: scale(0.96 → 1) с небольшим подъёмом. */
|
||||
@keyframes gl-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gl-pop-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.gl-pop-in {
|
||||
animation: gl-pop-in var(--duration-base) var(--ease-out) backwards;
|
||||
}
|
||||
|
||||
.gl-pop-out {
|
||||
animation: gl-pop-out var(--duration-fast) var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
/* Всплывающее меню/поповер: fade + сдвиг от якоря (меню растёт вверх). */
|
||||
@keyframes gl-popover-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.gl-popover {
|
||||
transform-origin: bottom;
|
||||
animation: gl-popover-in var(--duration-fast) var(--ease-out) backwards;
|
||||
}
|
||||
|
||||
/*
|
||||
* Каскадное появление списка. Задержка растёт только у первых восьми
|
||||
* элементов — дальше список появляется без ступенек, чтобы длинные
|
||||
* списки не «догоняли» пользователя.
|
||||
*/
|
||||
.gl-stagger > * {
|
||||
animation: gl-page-in var(--duration-base) var(--ease-out) backwards;
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(2) {
|
||||
animation-delay: calc(var(--stagger-step) * 1);
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(3) {
|
||||
animation-delay: calc(var(--stagger-step) * 2);
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(4) {
|
||||
animation-delay: calc(var(--stagger-step) * 3);
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(5) {
|
||||
animation-delay: calc(var(--stagger-step) * 4);
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(6) {
|
||||
animation-delay: calc(var(--stagger-step) * 5);
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(7) {
|
||||
animation-delay: calc(var(--stagger-step) * 6);
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(8) {
|
||||
animation-delay: calc(var(--stagger-step) * 7);
|
||||
}
|
||||
|
||||
.gl-stagger > *:nth-child(n + 9) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
|
||||
/* Индикатор активной комнаты: полоска слева плавно «вырастает». */
|
||||
@keyframes gl-indicator-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleY(0.3);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.gl-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
border-radius: 999px;
|
||||
background-color: var(--color-accent);
|
||||
transform: translateY(-50%);
|
||||
animation: gl-indicator-in var(--duration-base) var(--ease-out) backwards;
|
||||
}
|
||||
|
||||
/* Индикатор активного сервера в рейке: кольцо, проявляющееся из центра. */
|
||||
@keyframes gl-ring-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.gl-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border: 2px solid var(--color-accent);
|
||||
border-radius: var(--radius-lg);
|
||||
pointer-events: none;
|
||||
animation: gl-ring-in var(--duration-base) var(--ease-out) backwards;
|
||||
}
|
||||
|
||||
/* Скелетон загрузки: полоса с бегущим блеском. */
|
||||
@keyframes gl-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.gl-skeleton {
|
||||
display: block;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-surface-2);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--color-surface-2) 25%,
|
||||
var(--color-surface-3) 50%,
|
||||
var(--color-surface-2) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: gl-shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
* Доступность: при `prefers-reduced-motion: reduce` анимации становятся
|
||||
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
|
||||
* появлялись бы по очереди без анимации.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-delay: 0ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user