feat(web): друзья, личные беседы и вход только по приглашению

Раздел «Друзья» по запросу пользователя:

- первая иконка рейки — «Друзья», серверы ниже за разделителем; личные беседы
  не засоряют список серверов;
- сайдбар друзей: беседы с точками статуса и бейджами непрочитанного,
  список друзей с кликом на беседу;
- контент: пустое состояние «Добавь друзей для общения» с поиском, поиск по
  логину (debounce), добавление в друзья, входящие и исходящие заявки,
  меню «…» (удалить/заблокировать), поповер последнего входа в часовом поясе
  пользователя (по умолчанию МСК), блок «Присоединиться по приглашению»
  с предпросмотром сервера;
- личный чат на существующих компонентах: права по can_send канала, заголовок
  с именем и статусом собеседника, поиск, пины, ack и typing работают;
- realtime: READY dm_channels, события DM_CHANNEL_CREATE, RELATIONSHIP_UPDATE,
  PRESENCE_UPDATE и MESSAGE_CREATE в беседах;
- новый пользователь: «У вас пока нет серверов», создание сервера и вход по
  приглашению (автовступления нет);
- 239 тестов Vitest (было 219), тест паритета ключей i18n (527 ключей).
This commit is contained in:
2026-09-20 01:52:27 +03:00
parent d02ef7527a
commit bcfc275bb3
15 changed files with 967 additions and 87 deletions
+3 -1
View File
@@ -137,7 +137,9 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
{t('friends.dmSection')}
</h2>
{direct.length === 0 ? (
<p className="px-2 text-xs text-fg-muted">{t('friends.dmEmpty')}</p>
<p className="px-2 text-xs text-fg-muted" data-testid="dm-sidebar-empty">
{t('friends.dmEmpty')}
</p>
) : (
<ul className="gl-stagger flex flex-col gap-0.5" data-testid="dm-list">
{direct.map((channel) => (
+1 -1
View File
@@ -52,7 +52,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
</svg>
</NavLink>
<div role="separator" aria-hidden="true" className="my-0.5 h-px w-8 bg-border/60" />
<div role="separator" className="my-0.5 h-px w-8 bg-border/60" />
<ul className="gl-stagger flex flex-col items-center gap-2" data-testid="guild-rail">
{guilds.map((guild) => {
+1 -1
View File
@@ -22,7 +22,7 @@ export default function NoGuildView() {
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
<Button className="mt-4" onClick={openGuildCreate}>
{t('guilds.add')}
{t('guilds.empty.create')}
</Button>
<InviteJoin className="mt-6 border-t border-border/60 pt-4" />