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:
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { removeRelationship, type RelationshipUser } from '@/api/friends';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||
import { Popover } from '@/components/ui/Popover';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
@@ -131,6 +132,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
||||
<p className="px-1 py-0.5 text-xs text-fg">{hint}</p>
|
||||
</Popover>
|
||||
|
||||
{openDm.isError || remove.isError ? (
|
||||
<span className="absolute right-2 top-full z-10 w-64">
|
||||
<ErrorNotice error={openDm.error ?? remove.error} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
label={t('friends.list.menu', { name: displayName })}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function FriendsView() {
|
||||
aria-expanded={searchOpen}
|
||||
onClick={() => setSearchOpen((value) => !value)}
|
||||
>
|
||||
{t('friends.empty.search')}
|
||||
{t('friends.search.open')}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -6,10 +6,14 @@ import { unreadCount, useMessagesStore, type ChannelMessagesState } from '@/stor
|
||||
/**
|
||||
* Непрочитанные личной беседы.
|
||||
*
|
||||
* Для беседы с загруженной историей счётчик считается как у комнат
|
||||
* (`unreadCount`). Пока история не загружена, сравниваем `last_message_id`
|
||||
* канала с отметкой чтения из READY/`READ_STATE_UPDATE`: точного числа в этом
|
||||
* случае нет, поэтому `exact = false` — в сайдбаре рисуем точку, а не цифру.
|
||||
* Два сигнала, как у комнат:
|
||||
* - загруженная история — точное число (`unreadCount`);
|
||||
* - `last_message_id` канала против отметки чтения из READY /
|
||||
* `READ_STATE_UPDATE` — когда история ещё не загружена, точного числа нет,
|
||||
* поэтому в сайдбаре рисуем точку (`exact = false`), а не цифру.
|
||||
*
|
||||
* Сигналы объединяются через максимум: сообщение в уже открытой беседе могло
|
||||
* прийти событием, когда история загружена, а отметки чтения ещё нет.
|
||||
*/
|
||||
export function useDmUnread(channel: Channel): {
|
||||
unread: number;
|
||||
@@ -22,14 +26,17 @@ export function useDmUnread(channel: Channel): {
|
||||
[channel.id],
|
||||
),
|
||||
);
|
||||
// Отметки чтения известны после снапшота READY: отсутствие записи для
|
||||
// беседы означает «не читалась».
|
||||
const snapshotApplied = useMessagesStore((state) => state.snapshotApplied);
|
||||
const mentions = record?.mentionCount ?? 0;
|
||||
|
||||
if (record !== undefined && record.messages.length > 0) {
|
||||
return { unread: unreadCount(record), mentions, exact: true };
|
||||
}
|
||||
|
||||
const known = record?.readStateKnown === true;
|
||||
const hasHistory = record !== undefined && record.messages.length > 0;
|
||||
const fromHistory = hasHistory ? unreadCount(record) : 0;
|
||||
const known = record?.readStateKnown === true || snapshotApplied;
|
||||
const marker = record?.lastReadMessageId ?? null;
|
||||
const hasNewer = channel.last_message_id !== undefined && channel.last_message_id !== marker;
|
||||
return { unread: known && hasNewer ? 1 : 0, mentions, exact: false };
|
||||
const unread = Math.max(fromHistory, known && hasNewer ? 1 : 0);
|
||||
|
||||
return { unread, mentions, exact: fromHistory > 0 };
|
||||
}
|
||||
|
||||
@@ -176,7 +176,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "You have no servers yet",
|
||||
"body": "Create your own server or join one with an invite."
|
||||
"body": "Create your own server or join one with an invite.",
|
||||
"create": "Create your own server"
|
||||
},
|
||||
"leave": "Leave server",
|
||||
"leaveConfirm": "Leave the server “{{name}}”?",
|
||||
@@ -249,7 +250,8 @@
|
||||
"adding": "Sending…",
|
||||
"requested": "Request sent",
|
||||
"friend": "Already friends",
|
||||
"message": "Message"
|
||||
"message": "Message",
|
||||
"open": "Search friends"
|
||||
},
|
||||
"requests": {
|
||||
"incoming": "Incoming requests",
|
||||
|
||||
@@ -176,7 +176,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "У вас пока нет серверов",
|
||||
"body": "Создайте свой сервер или присоединитесь по приглашению."
|
||||
"body": "Создайте свой сервер или присоединитесь по приглашению.",
|
||||
"create": "Создать свой сервер"
|
||||
},
|
||||
"leave": "Покинуть сервер",
|
||||
"leaveConfirm": "Покинуть сервер «{{name}}»?",
|
||||
@@ -249,7 +250,8 @@
|
||||
"adding": "Отправляем…",
|
||||
"requested": "Заявка отправлена",
|
||||
"friend": "Уже в друзьях",
|
||||
"message": "Написать"
|
||||
"message": "Написать",
|
||||
"open": "Поиск друзей"
|
||||
},
|
||||
"requests": {
|
||||
"incoming": "Входящие заявки",
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -199,6 +199,8 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
const message = parseMessagePayload(event.d);
|
||||
if (message !== null) {
|
||||
messages.applyMessageCreate(message);
|
||||
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
|
||||
session.touchDmChannel(message.channel_id, message.id, message.created_at);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ interface SessionState {
|
||||
upsertDmChannel: (channel: Channel) => void;
|
||||
/** PRESENCE_UPDATE: статус собеседника в списке бесед и шапке чата. */
|
||||
patchDmPresence: (userId: string, status: UserStatus | undefined) => void;
|
||||
/** MESSAGE_CREATE в беседе: сдвигаем «последнее сообщение» (бейджи и порядок). */
|
||||
touchDmChannel: (channelId: string, messageId: string, at: string) => void;
|
||||
/** DM_CHANNEL_CREATE: перечитываем беседы по REST. */
|
||||
reloadDmChannels: () => Promise<void>;
|
||||
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
|
||||
@@ -269,6 +271,19 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
touchDmChannel: (channelId, messageId, at) => {
|
||||
if (!get().dmChannels.some((channel) => channel.id === channelId)) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
dmChannels: get().dmChannels.map((channel) =>
|
||||
channel.id === channelId
|
||||
? { ...channel, last_message_id: messageId, last_message_at: at }
|
||||
: channel,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
reloadDmChannels: async () => {
|
||||
try {
|
||||
set({ dmChannels: await fetchDirectChannels() });
|
||||
|
||||
Reference in New Issue
Block a user