fix(files): аватары и баннеры доступны авторизованным пользователям

Аватар нельзя было показать в списке друзей: файл без комнаты был доступен
только загрузившему. Добавлено назначение файла (`files.purpose`: attachment |
avatar | banner, миграция 00006): аватары и баннеры отдаются любому
авторизованному пользователю, вложения по-прежнему проверяются по правам
комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
2026-09-20 01:39:04 +03:00
parent 20eed227ca
commit d02ef7527a
35 changed files with 2330 additions and 191 deletions
@@ -0,0 +1,7 @@
-- +goose Up
-- Назначение файла: вложения видны по правам комнаты, аватары и баннеры —
-- всем авторизованным (AGENT.md 7.2, 7.7).
ALTER TABLE files ADD COLUMN purpose TEXT NOT NULL DEFAULT 'attachment';
-- +goose Down
ALTER TABLE files DROP COLUMN purpose;
+7
View File
@@ -130,6 +130,7 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
UploaderID: &currentUser.ID, UploaderID: &currentUser.ID,
Filename: sanitizeFilename(header.Filename), Filename: sanitizeFilename(header.Filename),
ContentType: contentType, ContentType: contentType,
Purpose: "avatar",
}, bytes.NewReader(data)) }, bytes.NewReader(data))
if err != nil { if err != nil {
writeHumaAPIError(w, err) writeHumaAPIError(w, err)
@@ -295,6 +296,11 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
if err != nil { if err != nil {
return nil, humaError(err) return nil, humaError(err)
} }
// Аватары и баннеры видны всем авторизованным: они показываются в списках
// участников и друзей (AGENT.md 7.2).
if file.Purpose == "avatar" || file.Purpose == "banner" {
return file, nil
}
if file.ChannelID != nil { if file.ChannelID != nil {
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil { if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
return nil, err return nil, err
@@ -355,6 +361,7 @@ func (s *Server) saveUpload(ctx context.Context, file store.File, content io.Rea
SizeBytes: written, SizeBytes: written,
StoragePath: path, StoragePath: path,
SHA256: hex.EncodeToString(hasher.Sum(nil)), SHA256: hex.EncodeToString(hasher.Sum(nil)),
Purpose: file.Purpose,
}) })
if err != nil { if err != nil {
_ = os.Remove(path) _ = os.Remove(path)
+19
View File
@@ -254,6 +254,25 @@ func TestTimezoneAndAvatarUpdate(t *testing.T) {
t.Fatalf("avatar_file_id = %q, want %q", profile.User.AvatarFileID, fileID) t.Fatalf("avatar_file_id = %q, want %q", profile.User.AvatarFileID, fileID)
} }
// Аватар виден другому авторизованному пользователю (он показывается в
// списках друзей и участников), но не анонимному.
otherCookie := registerAndLogin(t, srv, "tz_other", "tz-other@example.com")
_ = otherCookie
downloadReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
httpServer.URL+"/files/"+fileID, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
downloadReq.AddCookie(otherCookie)
downloadResp, err := httpServer.Client().Do(downloadReq)
if err != nil {
t.Fatalf("avatar download: %v", err)
}
_ = downloadResp.Body.Close()
if downloadResp.StatusCode != http.StatusOK {
t.Fatalf("другой пользователь не видит аватар: %d", downloadResp.StatusCode)
}
removed := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/avatar", "", cookie) removed := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/avatar", "", cookie)
if removed.Code != http.StatusOK { if removed.Code != http.StatusOK {
t.Fatalf("delete avatar = %d, body = %s", removed.Code, removed.Body.String()) t.Fatalf("delete avatar = %d, body = %s", removed.Code, removed.Body.String())
+12 -5
View File
@@ -20,6 +20,9 @@ type File struct {
Height int Height int
StoragePath string StoragePath string
SHA256 string SHA256 string
// Purpose: attachment (по умолчанию) | avatar | banner — от него зависит
// проверка доступа при выдаче файла.
Purpose string
CreatedAt time.Time CreatedAt time.Time
} }
@@ -36,23 +39,27 @@ type CreateFileParams struct {
Height int Height int
StoragePath string StoragePath string
SHA256 string SHA256 string
Purpose string
} }
const fileColumns = `id, uploader_id, guild_id, channel_id, message_id, filename, const fileColumns = `id, uploader_id, guild_id, channel_id, message_id, filename,
content_type, size_bytes, width, height, storage_path, sha256, created_at` content_type, size_bytes, width, height, storage_path, sha256, created_at, purpose`
// CreateFile регистрирует загруженный файл. // CreateFile регистрирует загруженный файл.
func (s *Store) CreateFile(ctx context.Context, params CreateFileParams) (*File, error) { func (s *Store) CreateFile(ctx context.Context, params CreateFileParams) (*File, error) {
if params.ID == 0 { if params.ID == 0 {
params.ID = s.NextID() params.ID = s.NextID()
} }
if params.Purpose == "" {
params.Purpose = "attachment"
}
_, err := s.writer.ExecContext(ctx, ` _, err := s.writer.ExecContext(ctx, `
INSERT INTO files (id, uploader_id, guild_id, channel_id, message_id, filename, INSERT INTO files (id, uploader_id, guild_id, channel_id, message_id, filename,
content_type, size_bytes, width, height, storage_path, sha256, created_at) content_type, size_bytes, width, height, storage_path, sha256, created_at, purpose)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
int64(params.ID), int64(params.UploaderID), nullableID(params.GuildID), nullableID(params.ChannelID), int64(params.ID), int64(params.UploaderID), nullableID(params.GuildID), nullableID(params.ChannelID),
params.Filename, params.ContentType, params.SizeBytes, params.Width, params.Height, params.Filename, params.ContentType, params.SizeBytes, params.Width, params.Height,
params.StoragePath, params.SHA256, s.Now()) params.StoragePath, params.SHA256, s.Now(), params.Purpose)
if err != nil { if err != nil {
return nil, mapError(err) return nil, mapError(err)
} }
@@ -126,7 +133,7 @@ func scanFile(scanner interface{ Scan(...any) error }) (*File, error) {
) )
err := scanner.Scan(&file.ID, &uploaderID, &guildID, &channelID, &messageID, &file.Filename, err := scanner.Scan(&file.ID, &uploaderID, &guildID, &channelID, &messageID, &file.Filename,
&file.ContentType, &file.SizeBytes, &file.Width, &file.Height, &file.StoragePath, &file.ContentType, &file.SizeBytes, &file.Width, &file.Height, &file.StoragePath,
&file.SHA256, &createdAt) &file.SHA256, &createdAt, &file.Purpose)
if err != nil { if err != nil {
return nil, mapError(err) return nil, mapError(err)
} }
+245
View File
@@ -0,0 +1,245 @@
import { request } from './client';
import { userStatuses, type Channel, type UserStatus } from './types';
/**
* Друзья, поиск пользователей и личные беседы (AGENT.md §7.8).
*
* Все ответы разбираются вручную: сервер помечает необязательные поля
* `omitempty`, поэтому доверять форме «как есть» нельзя — неизвестные и
* пропущенные поля не должны ломать список.
*/
/** Тип связи с пользователем. */
export const relationshipKinds = ['friend', 'incoming', 'outgoing', 'blocked', 'none'] as const;
export type RelationshipKind = (typeof relationshipKinds)[number];
/** Пользователь вместе с типом связи — форма `GET /users/@me/relationships`. */
export interface RelationshipUser {
user_id: string;
username: string;
display_name: string;
avatar_file_id?: string;
/** Статус, выставленный пользователем (может быть `invisible`). */
status: UserStatus;
/** Статус, который видят другие: «невидимка» приходит как `offline`. */
visible_status: UserStatus;
custom_status?: string;
is_instance_admin: boolean;
badges: string[];
last_seen_at?: string;
/** Часовой пояс собеседника (для справки; время показываем в своём). */
timezone: string;
relationship: RelationshipKind;
}
export interface Relationships {
friends: RelationshipUser[];
incoming: RelationshipUser[];
outgoing: RelationshipUser[];
blocked: RelationshipUser[];
}
export const relationshipsQueryKey = ['users', '@me', 'relationships'] as const;
export function userSearchQueryKey(query: string) {
return ['users', 'search', query] as const;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
function asStatus(value: unknown): UserStatus | undefined {
return userStatuses.includes(value as UserStatus) ? (value as UserStatus) : undefined;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
/** Разбирает пользователя со связью; `null` — запись не по контракту. */
export function parseRelationshipUser(value: unknown): RelationshipUser | null {
const record = asRecord(value);
const userId = asString(record?.['user_id']);
if (record === null || userId === undefined) {
return null;
}
const username = asString(record['username']) ?? userId;
const status = asStatus(record['status']) ?? 'offline';
const user: RelationshipUser = {
user_id: userId,
username,
display_name: asString(record['display_name']) ?? username,
// Видимый статус сервер считает сам; если его нет — берём обычный.
status,
visible_status: asStatus(record['visible_status']) ?? status,
is_instance_admin: record['is_instance_admin'] === true,
badges: asStringArray(record['badges']),
timezone: asString(record['timezone']) ?? 'Europe/Moscow',
relationship: (relationshipKinds as readonly string[]).includes(
record['relationship'] as string,
)
? (record['relationship'] as RelationshipKind)
: 'none',
};
const avatar = asString(record['avatar_file_id']);
const customStatus = asString(record['custom_status']);
const lastSeenAt = asString(record['last_seen_at']);
if (avatar !== undefined) {
user.avatar_file_id = avatar;
}
if (customStatus !== undefined) {
user.custom_status = customStatus;
}
if (lastSeenAt !== undefined) {
user.last_seen_at = lastSeenAt;
}
return user;
}
function parseList(value: unknown): RelationshipUser[] {
return Array.isArray(value)
? value.map(parseRelationshipUser).filter((item): item is RelationshipUser => item !== null)
: [];
}
export function parseRelationships(value: unknown): Relationships {
const record = asRecord(value);
return {
friends: parseList(record?.['friends']),
incoming: parseList(record?.['incoming']),
outgoing: parseList(record?.['outgoing']),
blocked: parseList(record?.['blocked']),
};
}
/** `GET /users/@me/relationships` — друзья, входящие и исходящие заявки. */
export async function fetchRelationships(signal?: AbortSignal): Promise<Relationships> {
const payload = await request<unknown>(
'/users/@me/relationships',
signal === undefined ? {} : { signal },
);
return parseRelationships(payload);
}
/** `GET /users/search?q=` — поиск по логину (минимум 2 символа). */
export async function searchUsers(
query: string,
signal?: AbortSignal,
): Promise<RelationshipUser[]> {
const payload = await request<{ users?: unknown }>(
`/users/search?q=${encodeURIComponent(query)}`,
signal === undefined ? {} : { signal },
);
return parseList(payload.users);
}
/** `POST /users/@me/relationships` — заявка в друзья по логину или id. */
export async function sendFriendRequest(input: {
username?: string;
user_id?: string;
}): Promise<void> {
await request<{ ok: true }>('/users/@me/relationships', { method: 'POST', body: input });
}
/** `POST /users/@me/relationships/{id}/accept` — принять входящую заявку. */
export async function acceptFriendRequest(userId: string): Promise<void> {
await request<{ ok: true }>(`/users/@me/relationships/${encodeURIComponent(userId)}/accept`, {
method: 'POST',
});
}
/**
* `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
* тот же вызов (AGENT.md §7.8).
*/
export async function removeRelationship(userId: string): Promise<void> {
await request<{ ok: true }>(`/users/@me/relationships/${encodeURIComponent(userId)}`, {
method: 'DELETE',
});
}
/** Личная беседа в REST-форме (`GET /users/@me/channels`). */
export interface DirectChannelPayload {
id?: unknown;
can_send?: unknown;
can_view?: unknown;
recipient?: unknown;
last_message_id?: unknown;
last_message_at?: unknown;
}
/**
* Приводит личную беседу из REST к общей форме `Channel`: чат и сайдбар
* работают с одним типом, поэтому имя и аватар берём у собеседника.
*/
export function parseDirectChannel(value: unknown): Channel | null {
const record = asRecord(value);
const id = asString(record?.['id']);
if (record === null || id === undefined) {
return null;
}
const recipient = asRecord(record['recipient']);
const username = asString(recipient?.['username']) ?? '';
const channel: Channel = {
id,
name: asString(recipient?.['display_name']) ?? username,
type: 'dm',
position: 0,
can_send: record['can_send'] !== false,
can_view: record['can_view'] !== false,
};
const recipientId = asString(recipient?.['user_id']);
const avatar = asString(recipient?.['avatar_file_id']);
// Наружу отдаём видимый статус: «невидимка» выглядит как офлайн.
const status = asStatus(recipient?.['visible_status']) ?? asStatus(recipient?.['status']);
const lastMessageId = asString(record['last_message_id']);
const lastMessageAt = asString(record['last_message_at']);
if (recipientId !== undefined) {
channel.recipient_id = recipientId;
}
if (avatar !== undefined) {
channel.icon_file_id = avatar;
}
if (status !== undefined) {
channel.status = status;
}
if (lastMessageId !== undefined) {
channel.last_message_id = lastMessageId;
}
if (lastMessageAt !== undefined) {
channel.last_message_at = lastMessageAt;
}
return channel;
}
/** `GET /users/@me/channels` — все личные беседы пользователя. */
export async function fetchDirectChannels(signal?: AbortSignal): Promise<Channel[]> {
const payload = await request<{ channels?: unknown }>(
'/users/@me/channels',
signal === undefined ? {} : { signal },
);
return Array.isArray(payload.channels)
? payload.channels.map(parseDirectChannel).filter((item): item is Channel => item !== null)
: [];
}
/** `POST /users/@me/channels` — открыть (или создать) беседу с пользователем. */
export async function openDirectChannel(recipientId: string): Promise<Channel> {
const payload = await request<{ channel?: unknown }>('/users/@me/channels', {
method: 'POST',
body: { recipient_id: recipientId },
});
const channel = parseDirectChannel(payload.channel);
if (channel === null) {
throw new Error('malformed direct channel payload');
}
return channel;
}
+86 -1
View File
@@ -194,7 +194,7 @@ export function parseChannelPayload(value: unknown): Channel | null {
if (id === undefined || name === undefined) { if (id === undefined || name === undefined) {
return null; return null;
} }
if (type !== 'text' && type !== 'voice' && type !== 'category') { if (type !== 'text' && type !== 'voice' && type !== 'category' && type !== 'dm') {
return null; return null;
} }
const channel: Channel = { const channel: Channel = {
@@ -226,9 +226,94 @@ export function parseChannelPayload(value: unknown): Channel | null {
if (typeof record['can_view'] === 'boolean') { if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view']; channel.can_view = record['can_view'];
} }
// Личная беседа: собеседник и последнее сообщение (READY и CHANNEL_*).
const recipientId = asString(record['recipient_id']);
const iconFileId = asString(record['icon_file_id']);
const status = asString(record['status']);
const lastMessageId = asString(record['last_message_id']);
const lastMessageAt = asString(record['last_message_at']);
if (recipientId !== undefined) {
channel.recipient_id = recipientId;
}
if (iconFileId !== undefined) {
channel.icon_file_id = iconFileId;
}
if (userStatuses.includes(status as UserStatus)) {
channel.status = status as UserStatus;
}
if (lastMessageId !== undefined) {
channel.last_message_id = lastMessageId;
}
if (lastMessageAt !== undefined) {
channel.last_message_at = lastMessageAt;
}
return channel; return channel;
} }
/* ------------------------------------------------------------------ */
/* Друзья и личные беседы (AGENT.md §8.3 / 7.8) */
/* ------------------------------------------------------------------ */
export interface DmChannelEvent {
channel_id: string;
}
export interface RelationshipEvent {
user_id: string;
}
export interface PresenceEvent {
user_id: string;
status?: UserStatus;
visible_status?: UserStatus;
custom_status?: string;
last_seen_at?: string;
}
/** `DM_CHANNEL_CREATE`: создана личная беседа — перечитываем список бесед. */
export function parseDmChannelEvent(value: unknown): DmChannelEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
return record === null || channelId === undefined ? null : { channel_id: channelId };
}
/** `RELATIONSHIP_UPDATE`: изменилась связь с пользователем. */
export function parseRelationshipEvent(value: unknown): RelationshipEvent | null {
const record = asRecord(value);
const userId = asString(record?.['user_id']);
return record === null || userId === undefined ? null : { user_id: userId };
}
/**
* `PRESENCE_UPDATE`: статус друга. Поля необязательны — сервер присылает
* только изменившиеся, поэтому разбираем каждое отдельно.
*/
export function parsePresenceEvent(value: unknown): PresenceEvent | null {
const record = asRecord(value);
const userId = asString(record?.['user_id']);
if (record === null || userId === undefined) {
return null;
}
const event: PresenceEvent = { user_id: userId };
const status = asString(record['status']);
const visibleStatus = asString(record['visible_status']);
const customStatus = asString(record['custom_status']);
const lastSeenAt = asString(record['last_seen_at']);
if (userStatuses.includes(status as UserStatus)) {
event.status = status as UserStatus;
}
if (userStatuses.includes(visibleStatus as UserStatus)) {
event.visible_status = visibleStatus as UserStatus;
}
if (customStatus !== undefined) {
event.custom_status = customStatus;
}
if (lastSeenAt !== undefined) {
event.last_seen_at = lastSeenAt;
}
return event;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* События сообщений Фазы 2 (AGENT.md §8.3) */ /* События сообщений Фазы 2 (AGENT.md §8.3) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
+107
View File
@@ -0,0 +1,107 @@
import { request } from './client';
/**
* Приглашения на сервер (AGENT.md §7.9).
*
* Присоединиться можно только по приглашению, поэтому клиент умеет:
* разобрать код из ссылки `https://<домен>/invite/<код>`, показать
* предпросмотр (`GET /invites/{code}`) и принять его (`POST /invites/{code}`).
*/
export interface InviteGuildPreview {
id: string;
name: string;
icon_file_id?: string;
description?: string;
member_count: number;
is_member: boolean;
}
export interface InvitePreview {
code: string;
guild_id: string;
guild: InviteGuildPreview;
}
export const inviteQueryKey = (code: string) => ['invites', code] as const;
/**
* Достаёт код приглашения из ссылки или «сырого» кода.
* Возвращает `null`, если строка не похожа ни на то, ни на другое.
*/
export function parseInviteCode(input: string): string | null {
const trimmed = input.trim();
if (trimmed === '') {
return null;
}
const fromUrl = (value: string): string | null => {
try {
const url = new URL(value);
const segments = url.pathname.split('/').filter((segment) => segment !== '');
const index = segments.indexOf('invite');
const code = index === -1 ? undefined : segments[index + 1];
return code === undefined || code === '' ? null : code;
} catch {
return null;
}
};
if (/^https?:\/\//iu.test(trimmed)) {
return fromUrl(trimmed);
}
// Сырой код: буквы и цифры без разделителей (алфавит сервера — a-z0-9).
return /^[0-9a-z]+$/iu.test(trimmed) ? trimmed : null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
/** `GET /invites/{code}` — предпросмотр приглашения. */
export async function fetchInvite(code: string, signal?: AbortSignal): Promise<InvitePreview> {
const payload = await request<unknown>(
`/invites/${encodeURIComponent(code)}`,
signal === undefined ? {} : { signal },
);
const record = asRecord(payload);
const invite = asRecord(record?.['invite']);
const guild = asRecord(record?.['guild']);
const guildId = asString(guild?.['id']);
if (guild === null || guildId === undefined) {
throw new Error('malformed invite payload');
}
const preview: InvitePreview = {
code: asString(invite?.['code']) ?? code,
guild_id: guildId,
guild: {
id: guildId,
name: asString(guild['name']) ?? guildId,
member_count: typeof guild['member_count'] === 'number' ? guild['member_count'] : 0,
is_member: guild['is_member'] === true,
},
};
const icon = asString(guild['icon_file_id']);
const description = asString(guild['description']);
if (icon !== undefined) {
preview.guild.icon_file_id = icon;
}
if (description !== undefined) {
preview.guild.description = description;
}
return preview;
}
/** `POST /invites/{code}` — вступить на сервер; сервер отвечает его id. */
export async function acceptInvite(code: string): Promise<string> {
const payload = await request<{ guild_id?: unknown }>(`/invites/${encodeURIComponent(code)}`, {
method: 'POST',
});
const guildId = asString(payload.guild_id);
if (guildId === undefined) {
throw new Error('malformed invite accept payload');
}
return guildId;
}
+12 -2
View File
@@ -8,8 +8,8 @@
export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const; export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const;
export type UserStatus = (typeof userStatuses)[number]; export type UserStatus = (typeof userStatuses)[number];
/** Тип комнаты: текст, голосовой канал или категория. */ /** Тип комнаты: текст, голосовой канал, категория или личная беседа. */
export type ChannelType = 'text' | 'voice' | 'category'; export type ChannelType = 'text' | 'voice' | 'category' | 'dm';
/** Пользователь в объёме, достаточном для интерфейса (GET /users/{id}). */ /** Пользователь в объёме, достаточном для интерфейса (GET /users/{id}). */
export interface User { export interface User {
@@ -75,6 +75,16 @@ export interface Channel {
can_view?: boolean; can_view?: boolean;
can_send?: boolean; can_send?: boolean;
can_connect?: boolean; can_connect?: boolean;
/**
* Поля личной беседы (`type: 'dm'`): id собеседника, его аватар и видимый
* статус. В снапшоте READY аватар приходит как `icon_file_id`, в REST-списке
* бесед — внутри `recipient`.
*/
recipient_id?: string;
icon_file_id?: string;
status?: UserStatus;
last_message_id?: string;
last_message_at?: string;
} }
export interface Guild { export interface Guild {
+11 -3
View File
@@ -97,9 +97,15 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
const currentUserId = sessionUser?.id ?? null; const currentUserId = sessionUser?.id ?? null;
const permissions = guild?.my_permissions ?? []; const permissions = guild?.my_permissions ?? [];
const isAdmin = sessionUser?.is_instance_admin === true; const isAdmin = sessionUser?.is_instance_admin === true;
const canManage = canManageMessagesPermission(permissions, isAdmin); // Личная беседа: прав сервера нет, отправку разрешает сам канал (`can_send`),
const canSend = canSendMessages(permissions, isAdmin, channel.can_send); // а модерация сообщений в беседах не применяется.
const canAttach = canSend && (isAdmin || hasPermission(permissions, PERMISSION_ATTACH_FILES)); const isDirect = channel.type === 'dm';
const canManage = !isDirect && canManageMessagesPermission(permissions, isAdmin);
const canSend = isDirect
? channel.can_send !== false
: canSendMessages(permissions, isAdmin, channel.can_send);
const canAttach =
canSend && (isDirect || isAdmin || hasPermission(permissions, PERMISSION_ATTACH_FILES));
const disabledReason = canSend ? null : t('chat.composer.readOnly'); const disabledReason = canSend ? null : t('chat.composer.readOnly');
const windowActive = useWindowActive(); const windowActive = useWindowActive();
@@ -189,6 +195,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
<MessageList <MessageList
channelId={channelId} channelId={channelId}
channelName={channel.name} channelName={channel.name}
direct={isDirect}
messages={messages} messages={messages}
status={record.status} status={record.status}
error={record.error} error={record.error}
@@ -212,6 +219,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
<Composer <Composer
channel={channel} channel={channel}
authors={authors} authors={authors}
direct={isDirect}
replyTo={replyTo} replyTo={replyTo}
onCancelReply={() => setReplyTo(null)} onCancelReply={() => setReplyTo(null)}
disabledReason={disabledReason} disabledReason={disabledReason}
+9 -2
View File
@@ -14,6 +14,8 @@ import { useMessagesStore } from '@/stores/messages';
interface ComposerProps { interface ComposerProps {
channel: Channel; channel: Channel;
authors: AuthorDirectory; authors: AuthorDirectory;
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
direct?: boolean;
/** Сообщение, на которое отвечаем (плашка над полем ввода). */ /** Сообщение, на которое отвечаем (плашка над полем ввода). */
replyTo: Message | null; replyTo: Message | null;
onCancelReply: () => void; onCancelReply: () => void;
@@ -34,6 +36,7 @@ const LINE_HEIGHT_PX = 22;
export function Composer({ export function Composer({
channel, channel,
authors, authors,
direct = false,
replyTo, replyTo,
onCancelReply, onCancelReply,
disabledReason, disabledReason,
@@ -291,8 +294,12 @@ export function Composer({
/> />
<textarea <textarea
ref={textareaRef} ref={textareaRef}
aria-label={t('chat.composer.label', { name: channel.name })} aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
placeholder={t('chat.composer.placeholder', { name: channel.name })} name: channel.name,
})}
placeholder={t(direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder', {
name: channel.name,
})}
className="max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent py-1 text-sm text-fg placeholder:text-fg-muted/70 focus:outline-none disabled:opacity-60" className="max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent py-1 text-sm text-fg placeholder:text-fg-muted/70 focus:outline-none disabled:opacity-60"
rows={1} rows={1}
value={value} value={value}
+12 -3
View File
@@ -19,6 +19,8 @@ import {
interface MessageListProps { interface MessageListProps {
channelId: string; channelId: string;
channelName: string; channelName: string;
/** Личная беседа: тексты без «#» и без упоминания комнаты. */
direct?: boolean;
/** Лента с оптимистичными сообщениями в конце. */ /** Лента с оптимистичными сообщениями в конце. */
messages: Message[]; messages: Message[];
status: 'idle' | 'loading' | 'ready' | 'error'; status: 'idle' | 'loading' | 'ready' | 'error';
@@ -48,6 +50,7 @@ const STICK_THRESHOLD_PX = 120;
export function MessageList({ export function MessageList({
channelId, channelId,
channelName, channelName,
direct = false,
messages, messages,
status, status,
error, error,
@@ -140,7 +143,9 @@ export function MessageList({
ref={containerRef} ref={containerRef}
onScroll={onScroll} onScroll={onScroll}
role="log" role="log"
aria-label={t('chat.log', { name: channelName })} aria-label={
direct ? t('chat.logDirect', { name: channelName }) : t('chat.log', { name: channelName })
}
data-testid="message-list" data-testid="message-list"
className="min-h-0 flex-1 overflow-y-auto px-3 py-3" className="min-h-0 flex-1 overflow-y-auto px-3 py-3"
> >
@@ -159,9 +164,13 @@ export function MessageList({
{messages.length === 0 ? ( {messages.length === 0 ? (
<Card className="mx-auto mt-6 max-w-md text-center" data-testid="chat-start"> <Card className="mx-auto mt-6 max-w-md text-center" data-testid="chat-start">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t('chat.start.title', { name: channelName })} {direct
? t('chat.start.directTitle', { name: channelName })
: t('chat.start.title', { name: channelName })}
</h3> </h3>
<p className="mt-2 text-sm text-fg-muted">{t('chat.start.body')}</p> <p className="mt-2 text-sm text-fg-muted">
{t(direct ? 'chat.start.directBody' : 'chat.start.body')}
</p>
</Card> </Card>
) : ( ) : (
<ol className="flex flex-col"> <ol className="flex flex-col">
+28 -6
View File
@@ -63,10 +63,14 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
}, [membersData]); }, [membersData]);
const unique = useMemo(() => [...new Set(userIds)].filter((id) => id !== ''), [userIds]); const unique = useMemo(() => [...new Set(userIds)].filter((id) => id !== ''), [userIds]);
const missing = useMemo( // В личной беседе участников сервера нет: имена всех авторов берём из
() => (membersLoaded ? unique.filter((id) => !memberMap.has(id)) : []), // `GET /users/{id}`. В комнате — только те, кого нет в списке участников.
[unique, memberMap, membersLoaded], const missing = useMemo(() => {
); if (guildId === null) {
return unique;
}
return membersLoaded ? unique.filter((id) => !memberMap.has(id)) : [];
}, [guildId, unique, memberMap, membersLoaded]);
const users = useQueries({ const users = useQueries({
queries: missing.map((userId) => ({ queries: missing.map((userId) => ({
@@ -99,6 +103,24 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
return map; return map;
}, [namesKey]); }, [namesKey]);
// Аватары тех же авторов: в личных беседах список участников недоступен.
const avatarsKey = missing
.map((userId, index) => {
const data = users[index]?.data;
return data?.avatar_file_id === undefined ? '' : `${userId}=${data.avatar_file_id}`;
})
.join('|');
const userAvatarMap = useMemo(() => {
const map = new Map<string, string>();
for (const entry of avatarsKey.split('|')) {
const separator = entry.indexOf('=');
if (separator > 0) {
map.set(entry.slice(0, separator), entry.slice(separator + 1));
}
}
return map;
}, [avatarsKey]);
return useMemo<AuthorDirectory>(() => { return useMemo<AuthorDirectory>(() => {
const resolve = (userId: string | undefined): ChatAuthor => { const resolve = (userId: string | undefined): ChatAuthor => {
if (userId === undefined || userId === '') { if (userId === undefined || userId === '') {
@@ -126,7 +148,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
return { return {
id: userId, id: userId,
name: fallbackName ?? t('chat.unknownAuthor'), name: fallbackName ?? t('chat.unknownAuthor'),
avatarFileId: undefined, avatarFileId: userAvatarMap.get(userId),
colorHex: null, colorHex: null,
isInstanceAdmin: false, isInstanceAdmin: false,
isMember: false, isMember: false,
@@ -141,5 +163,5 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
}, },
loaded: membersLoaded, loaded: membersLoaded,
}; };
}, [memberMap, rolesData, userNameMap, currentUserId, membersLoaded, t]); }, [memberMap, rolesData, userNameMap, userAvatarMap, currentUserId, membersLoaded, t]);
} }
@@ -0,0 +1,147 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Message } from '@/api/messages';
import { ChatView } from '@/components/chat/ChatView';
import { PinsPanel } from '@/components/chat/PinsPanel';
import { SearchPanel } from '@/components/chat/SearchPanel';
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Avatar } from '@/components/ui/Avatar';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { presenceDotClass } from '@/lib/presence';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
/**
* Личная беседа в области контента: шапка с собеседником и его статусом,
* лента, поиск и закреплённые сообщения — тем же `ChatView`, что и в комнатах
* сервера (права участника беседы даёт сервер, отдельных прав сервера нет).
*/
export function DirectMessageView({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const channel = useSessionStore(
useCallback(
(state) => state.dmChannels.find((item) => item.id === channelId) ?? null,
[channelId],
),
);
const reloadDmChannels = useSessionStore((state) => state.reloadDmChannels);
// Прямая ссылка на беседу: список приходит снапшотом, но после перезагрузки
// страницы его может ещё не быть — подстраховываемся REST-запросом.
useEffect(() => {
if (channel === null) {
void reloadDmChannels();
}
}, [channel, reloadDmChannels]);
const [searchOpen, setSearchOpen] = useState(false);
const [pinsOpen, setPinsOpen] = useState(false);
const [jump, setJump] = useState<{ id: string; token: number } | null>(null);
const messages = useMessagesStore((state) => state.channels[channelId]?.messages);
const authorIds = useMemo(
() =>
(messages ?? [])
.map((message: Message) => message.author_id)
.filter((id): id is string => id !== undefined),
[messages],
);
const authors = useAuthorDirectory(null, authorIds);
const onJumpHandled = useCallback(() => setJump(null), []);
const goToMessage = useCallback((id: string) => {
setJump({ id, token: Date.now() });
}, []);
if (channel === null) {
return (
<section id="app-content" className="absolute inset-0 flex items-center justify-center p-6">
<Card className="max-w-md text-center" data-testid="dm-missing">
<h2 className="text-lg font-semibold">{t('friends.dm.notFound.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('friends.dm.notFound.body')}</p>
<div className="mt-4">
<SkeletonLines rows={2} />
</div>
</Card>
</section>
);
}
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
const status = channel.status ?? 'offline';
return (
<section id="app-content" className="absolute inset-0 flex flex-col">
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-2">
<span className="relative shrink-0">
<Avatar
name={name}
seed={channel.recipient_id ?? channel.id}
fileId={channel.icon_file_id}
size="sm"
/>
<span
aria-hidden="true"
data-testid="dm-status"
data-status={status}
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
/>
</span>
<h2 className="truncate text-sm font-semibold" title={name}>
{name}
</h2>
<span className="sr-only">{t(`user.status.${status}`)}</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
aria-label={t('chat.search.open')}
title={t('chat.search.open')}
aria-expanded={searchOpen}
className="rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setSearchOpen((open) => !open)}
>
🔍
</button>
<button
type="button"
aria-label={t('chat.pins.open')}
title={t('chat.pins.open')}
className="rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setPinsOpen(true)}
>
📌
</button>
</div>
</header>
<SearchPanel
channelId={channel.id}
open={searchOpen}
onClose={() => setSearchOpen(false)}
authors={authors}
onJump={goToMessage}
/>
<ChatView
key={channel.id}
channel={channel}
guildId={null}
jumpTo={jump}
onJumpHandled={onJumpHandled}
/>
<PinsPanel
channelId={channel.id}
channelName={name}
open={pinsOpen}
onClose={() => setPinsOpen(false)}
authors={authors}
canManageMessages={false}
onJump={(id) => {
setPinsOpen(false);
goToMessage(id);
}}
/>
</section>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useCallback, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { removeRelationship, type RelationshipUser } from '@/api/friends';
import { Avatar } from '@/components/ui/Avatar';
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { Popover } from '@/components/ui/Popover';
import { Button } from '@/components/ui/primitives';
import { DEFAULT_TIMEZONE } from '@/lib/timezones';
import { formatLastSeen, presenceDotClass } from '@/lib/presence';
import { useCurrentUser } from '@/lib/hooks';
import { useFriendsStore } from '@/stores/friends';
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
/**
* Строка списка друзей: аватар с точкой статуса, имя (с подсказкой о
* последнем входе) и действия — «Написать» и меню «…».
*
* Подсказка появляется по наведению и по фокусу: имя — кнопка, поэтому
* доступна с клавиатуры, а клик по ней открывает беседу.
*/
export function FriendRow({ friend }: { friend: RelationshipUser }) {
const { t, i18n } = useTranslation();
const currentUser = useCurrentUser();
const reload = useFriendsStore((state) => state.reload);
const openDm = useOpenDirectChannel();
const [hintOpen, setHintOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const nameRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLButtonElement | null>(null);
const remove = useMutation({
mutationFn: () => removeRelationship(friend.user_id),
onSuccess: () => reload(),
});
const displayName = friend.display_name === '' ? friend.username : friend.display_name;
const status = friend.visible_status;
const statusLabel = t(`user.status.${status}`);
// Время последнего входа — в часовом поясе пользователя из настроек.
const time = formatLastSeen(
friend.last_seen_at,
currentUser.data?.timezone ?? DEFAULT_TIMEZONE,
i18n.language,
);
const hint =
status !== 'offline'
? t('friends.lastSeen.online')
: time === null
? t('friends.lastSeen.unknown')
: t('friends.lastSeen.at', { time });
const closeHint = useCallback(() => setHintOpen(false), []);
return (
<li
className="relative flex items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
data-testid={`friend-row-${friend.user_id}`}
>
<button
ref={nameRef}
type="button"
aria-label={t('friends.open', { name: displayName })}
title={t('friends.open', { name: displayName })}
onClick={() => {
openDm.mutate(friend.user_id);
}}
onMouseEnter={() => setHintOpen(true)}
onMouseLeave={closeHint}
onFocus={() => setHintOpen(true)}
onBlur={closeHint}
className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-sm)] text-left"
data-testid={`friend-name-${friend.user_id}`}
>
<span className="relative shrink-0">
<Avatar
name={displayName}
seed={friend.user_id}
fileId={friend.avatar_file_id}
size="md"
/>
<span
aria-hidden="true"
data-testid={`friend-status-${friend.user_id}`}
data-status={status}
title={statusLabel}
className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{displayName}</span>
<span className="block truncate text-xs text-fg-muted">@{friend.username}</span>
</span>
</button>
<span className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
disabled={openDm.isPending}
onClick={() => {
openDm.mutate(friend.user_id);
}}
>
{t('friends.list.message')}
</Button>
<button
ref={menuRef}
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t('friends.list.more', { name: displayName })}
title={t('friends.list.more', { name: displayName })}
className="rounded-[var(--radius-sm)] px-1.5 py-1 text-sm leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setMenuOpen((value) => !value)}
>
<span aria-hidden="true">…</span>
</button>
</span>
<Popover
open={hintOpen}
label={t('friends.lastSeen.label')}
anchorRef={nameRef}
placement="bottom"
onClose={closeHint}
className="w-52"
>
<p className="px-1 py-0.5 text-xs text-fg">{hint}</p>
</Popover>
<Menu
open={menuOpen}
label={t('friends.list.menu', { name: displayName })}
anchorRef={menuRef}
placement="bottom"
align="right"
onClose={() => setMenuOpen(false)}
>
<MenuItem
icon="💬"
onClick={() => {
setMenuOpen(false);
openDm.mutate(friend.user_id);
}}
>
{t('friends.list.message')}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
icon="✖"
disabled={remove.isPending}
onClick={() => {
setMenuOpen(false);
remove.mutate();
}}
>
{t('friends.list.remove')}
</MenuItem>
<MenuItem
danger
icon="⛔"
disabled={remove.isPending}
onClick={() => {
setMenuOpen(false);
// Отдельной ручки блокировки нет: блокировка — тот же DELETE.
remove.mutate();
}}
>
{t('friends.list.block')}
</MenuItem>
</Menu>
</li>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { acceptFriendRequest, removeRelationship, type RelationshipUser } from '@/api/friends';
import { FriendRow } from '@/components/friends/FriendRow';
import { InviteJoin } from '@/components/friends/InviteJoin';
import { UserSearch } from '@/components/friends/UserSearch';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { Button } from '@/components/ui/primitives';
import { useFriendsStore } from '@/stores/friends';
import { useSessionStore } from '@/stores/session';
/** Имя для заявки: список связей не хранит отдельного «ника». */
function displayName(user: RelationshipUser): string {
return user.display_name === '' ? user.username : user.display_name;
}
/**
* Раздел «Друзья»: заявки, список друзей, поиск по логину и присоединение
* по приглашению. Личные беседы открываются в области контента (см.
* `DirectMessageView`), а их список живёт в сайдбаре раздела.
*/
export function FriendsView() {
const { t } = useTranslation();
const friends = useFriendsStore((state) => state.friends);
const incoming = useFriendsStore((state) => state.incoming);
const outgoing = useFriendsStore((state) => state.outgoing);
const status = useFriendsStore((state) => state.status);
const error = useFriendsStore((state) => state.error);
const load = useFriendsStore((state) => state.load);
const reload = useFriendsStore((state) => state.reload);
const dmChannels = useSessionStore((state) => state.dmChannels);
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
void load();
}, [load]);
const respond = useMutation({
mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) =>
kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId),
onSuccess: () => reload(),
});
// Пусто — только когда нет ни друзей, ни бесед, ни заявок: иначе заявку
// было бы не увидеть.
const isEmpty =
friends.length === 0 &&
dmChannels.length === 0 &&
incoming.length === 0 &&
outgoing.length === 0;
return (
<section id="app-content" className="absolute inset-0 overflow-y-auto p-4">
<header className="flex items-center gap-2">
<h2 className="text-sm font-semibold">{t('friends.title')}</h2>
<Button
variant="ghost"
className="ml-auto"
aria-expanded={searchOpen}
onClick={() => setSearchOpen((value) => !value)}
>
{t('friends.empty.search')}
</Button>
</header>
<div className="mt-3">
{searchOpen ? <UserSearch onClose={() => setSearchOpen(false)} /> : null}
</div>
{status === 'error' ? (
<ErrorNotice
error={error}
onRetry={() => {
void reload();
}}
/>
) : null}
{status === 'loading' && friends.length === 0 ? <SkeletonLines rows={3} /> : null}
{incoming.length === 0 ? null : (
<section aria-label={t('friends.requests.incoming')} className="mt-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.requests.incoming')}
</h3>
<ul className="gl-stagger mt-1 flex flex-col gap-1" data-testid="friends-incoming">
{incoming.map((user) => (
<li
key={user.user_id}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 hover:bg-surface-2"
>
<Avatar
name={displayName(user)}
seed={user.user_id}
fileId={user.avatar_file_id}
size="md"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{displayName(user)}</span>
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
</span>
<span className="flex shrink-0 items-center gap-1">
<Button
disabled={respond.isPending}
onClick={() => {
respond.mutate({ kind: 'accept', userId: user.user_id });
}}
>
{t('friends.requests.accept')}
</Button>
<Button
variant="ghost"
disabled={respond.isPending}
onClick={() => {
respond.mutate({ kind: 'decline', userId: user.user_id });
}}
>
{t('friends.requests.decline')}
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{outgoing.length === 0 ? null : (
<section aria-label={t('friends.requests.outgoing')} className="mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.requests.outgoing')}
</h3>
<ul className="gl-stagger mt-1 flex flex-col gap-1" data-testid="friends-outgoing">
{outgoing.map((user) => (
<li
key={user.user_id}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 hover:bg-surface-2"
>
<Avatar
name={displayName(user)}
seed={user.user_id}
fileId={user.avatar_file_id}
size="md"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{displayName(user)}</span>
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
</span>
<Button
variant="ghost"
disabled={respond.isPending}
onClick={() => {
respond.mutate({ kind: 'cancel', userId: user.user_id });
}}
>
{t('friends.requests.cancel')}
</Button>
</li>
))}
</ul>
</section>
)}
{isEmpty ? (
<div
className="flex flex-col items-center justify-center gap-3 px-4 py-12 text-center"
data-testid="friends-empty"
>
<h3 className="text-lg font-semibold">{t('friends.empty.title')}</h3>
<p className="max-w-md text-sm text-fg-muted">{t('friends.empty.body')}</p>
<Button onClick={() => setSearchOpen(true)}>{t('friends.empty.search')}</Button>
</div>
) : (
<section aria-label={t('friends.list.label')} className="mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.friendsSection')}
</h3>
{friends.length === 0 ? (
<p className="px-2 py-1 text-sm text-fg-muted">{t('friends.friendsEmpty')}</p>
) : (
<ul
className="gl-stagger mt-1 flex flex-col gap-0.5"
data-testid="friends-list"
aria-label={t('friends.list.label')}
>
{friends.map((friend) => (
<FriendRow key={friend.user_id} friend={friend} />
))}
</ul>
)}
</section>
)}
{respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null}
<InviteJoin className="mt-8" />
</section>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { acceptInvite, fetchInvite, inviteQueryKey, parseInviteCode } from '@/api/invites';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
/** Пауза перед предпросмотром: код или ссылку обычно вставляют целиком. */
export const INVITE_DEBOUNCE_MS = 400;
interface InviteJoinProps {
className?: string;
}
/**
* Присоединение по приглашению: поле для кода или ссылки, предпросмотр
* сервера и кнопка «Присоединиться».
*
* Действует и в разделе друзей, и на пустом экране серверов: новый
* пользователь попадает на сервер только по приглашению.
*/
export function InviteJoin({ className = '' }: InviteJoinProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const describeError = useApiErrorMessage();
const [value, setValue] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), INVITE_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [value]);
const code = parseInviteCode(debounced);
const invalid = debounced.trim() !== '' && code === null;
const preview = useQuery({
queryKey: inviteQueryKey(code ?? ''),
queryFn: ({ signal }) => fetchInvite(code ?? '', signal),
enabled: code !== null,
retry: 0,
});
const join = useMutation({
mutationFn: (target: string) => acceptInvite(target),
onSuccess: (guildId) => {
// Список серверов придёт снапшотом шлюза; REST — подстраховка.
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
// AppLayout подхватит сервер по URL и откроет его первый канал.
void navigate(`/app/${guildId}`);
},
});
return (
<section aria-label={t('invites.title')} className={className} data-testid="invite-join">
<h3 className="text-sm font-semibold">{t('invites.title')}</h3>
<p className="mt-1 text-xs text-fg-muted">{t('invites.hint')}</p>
<form
className="mt-2 flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
if (code !== null) {
join.mutate(code);
}
}}
>
<div className="flex-1">
<Field
label={t('invites.label')}
placeholder={t('invites.placeholder')}
value={value}
maxLength={256}
onChange={(event) => {
setValue(event.target.value);
join.reset();
}}
error={invalid ? t('invites.invalid') : undefined}
/>
</div>
<Button type="submit" className="mt-6" disabled={code === null || join.isPending}>
{t(join.isPending ? 'invites.joining' : 'invites.join')}
</Button>
</form>
{code !== null && preview.isFetching ? <SkeletonLines className="mt-3" rows={2} /> : null}
{code !== null && preview.isError ? (
<p role="alert" className="mt-3 text-sm text-danger">
{describeError(preview.error)}
</p>
) : null}
{preview.data === undefined ? null : (
<div
className="mt-3 flex items-center gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2"
data-testid="invite-preview"
>
<Avatar
name={preview.data.guild.name}
seed={preview.data.guild.id}
fileId={preview.data.guild.icon_file_id}
size="md"
shape="square"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{preview.data.guild.name}</span>
<span className="block text-xs text-fg-muted">
{t('invites.members', { count: preview.data.guild.member_count })}
</span>
</span>
{preview.data.guild.is_member ? (
<span className="text-xs text-fg-muted">{t('invites.alreadyMember')}</span>
) : null}
</div>
)}
{join.isError ? <ErrorNotice className="mt-3" error={join.error} /> : null}
</section>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
acceptFriendRequest,
searchUsers,
sendFriendRequest,
userSearchQueryKey,
type RelationshipUser,
} from '@/api/friends';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
import { useFriendsStore } from '@/stores/friends';
/** Пауза перед запросом: пользователь обычно печатает логин целиком. */
export const SEARCH_DEBOUNCE_MS = 400;
/** Сервер требует минимум два символа (`q` в `GET /users/search`). */
export const SEARCH_MIN_LENGTH = 2;
interface UserSearchProps {
onClose: () => void;
}
/**
* Поиск пользователей по логину с debounce и заявкой в друзья.
*
* Состояние связи берём из стора друзей, а не из ответа поиска: после
* «Добавить в друзья» кнопка сразу становится «Заявка отправлена».
* Компонент монтируется только при открытом поиске — состояние не залипает.
*/
export function UserSearch({ onClose }: UserSearchProps) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const [query, setQuery] = useState('');
const friends = useFriendsStore((state) => state.friends);
const outgoing = useFriendsStore((state) => state.outgoing);
const reload = useFriendsStore((state) => state.reload);
const openDm = useOpenDirectChannel();
useEffect(() => {
const trimmed = value.trim();
const timer = window.setTimeout(() => setQuery(trimmed), SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [value]);
const search = useQuery({
queryKey: userSearchQueryKey(query),
queryFn: ({ signal }) => searchUsers(query, signal),
enabled: query.length >= SEARCH_MIN_LENGTH,
retry: 0,
});
const add = useMutation({
mutationFn: (user: RelationshipUser) => sendFriendRequest({ username: user.username }),
onSuccess: () => reload(),
});
const accept = useMutation({
mutationFn: (userId: string) => acceptFriendRequest(userId),
onSuccess: () => reload(),
});
const results = search.data ?? [];
const tooShort = value.trim().length > 0 && value.trim().length < SEARCH_MIN_LENGTH;
return (
<section
aria-label={t('friends.search.title')}
className="gl-popover-down mb-3 rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-3"
data-testid="friends-search"
>
<div className="flex items-start gap-2">
<div className="flex-1">
<Field
label={t('friends.search.label')}
placeholder={t('friends.search.placeholder')}
value={value}
maxLength={32}
autoFocus
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
hint={tooShort ? t('friends.search.hint') : undefined}
/>
</div>
<Button variant="ghost" className="mt-6" onClick={onClose}>
{t('friends.search.close')}
</Button>
</div>
{search.isFetching ? <SkeletonLines className="mt-3" rows={2} /> : null}
{search.isError ? (
<ErrorNotice
className="mt-3"
error={search.error}
onRetry={() => {
void search.refetch();
}}
/>
) : null}
{!search.isFetching && query.length >= SEARCH_MIN_LENGTH && results.length === 0 ? (
<p className="mt-3 text-sm text-fg-muted">{t('friends.search.empty')}</p>
) : null}
<ul className="gl-stagger mt-2 flex flex-col gap-1">
{results.map((user) => {
const alreadyFriend = friends.some((item) => item.user_id === user.user_id);
const requested =
!alreadyFriend && outgoing.some((item) => item.user_id === user.user_id);
const incoming = !alreadyFriend && user.relationship === 'incoming';
return (
<li
key={user.user_id}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 hover:bg-surface-2"
>
<Avatar
name={user.display_name}
seed={user.user_id}
fileId={user.avatar_file_id}
size="md"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{user.display_name}</span>
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{alreadyFriend ? (
<span className="px-2 text-xs text-fg-muted">{t('friends.search.friend')}</span>
) : requested ? (
<span className="px-2 text-xs text-fg-muted">
{t('friends.search.requested')}
</span>
) : incoming ? (
<Button
disabled={accept.isPending}
onClick={() => {
accept.mutate(user.user_id);
}}
>
{t('friends.requests.accept')}
</Button>
) : (
<Button
variant="ghost"
disabled={add.isPending}
onClick={() => {
add.reset();
add.mutate(user);
}}
>
{t(add.isPending ? 'friends.search.adding' : 'friends.search.add')}
</Button>
)}
<Button
disabled={openDm.isPending}
onClick={() => {
openDm.mutate(user.user_id);
}}
>
{t('friends.search.message')}
</Button>
</span>
</li>
);
})}
</ul>
{add.isError ? <ErrorNotice className="mt-3" error={add.error} /> : null}
{accept.isError ? <ErrorNotice className="mt-3" error={accept.error} /> : null}
{openDm.isError ? <ErrorNotice className="mt-3" error={openDm.error} /> : null}
</section>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import type { Channel } from '@/api/types';
import { unreadCount, useMessagesStore, type ChannelMessagesState } from '@/stores/messages';
/**
* Непрочитанные личной беседы.
*
* Для беседы с загруженной историей счётчик считается как у комнат
* (`unreadCount`). Пока история не загружена, сравниваем `last_message_id`
* канала с отметкой чтения из READY/`READ_STATE_UPDATE`: точного числа в этом
* случае нет, поэтому `exact = false` — в сайдбаре рисуем точку, а не цифру.
*/
export function useDmUnread(channel: Channel): {
unread: number;
mentions: number;
exact: boolean;
} {
const record = useMessagesStore(
useCallback(
(state: { channels: Record<string, ChannelMessagesState> }) => state.channels[channel.id],
[channel.id],
),
);
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 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 };
}
@@ -0,0 +1,29 @@
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { openDirectChannel } from '@/api/friends';
import { useSessionStore } from '@/stores/session';
/**
* Открывает личную беседу с пользователем: `POST /users/@me/channels`
* возвращает уже существующий канал или создаёт новый, после чего беседа
* кладётся в снапшот сессии и открывается в области контента.
*/
export function useOpenDirectChannel() {
const navigate = useNavigate();
const upsertDmChannel = useSessionStore((state) => state.upsertDmChannel);
return useMutation({
mutationFn: async (recipientId: string) => {
// Беседа уже открыта — не дёргаем сервер лишний раз.
const existing = useSessionStore
.getState()
.dmChannels.find((channel) => channel.recipient_id === recipientId);
return existing ?? (await openDirectChannel(recipientId));
},
onSuccess: (channel) => {
upsertDmChannel(channel);
void navigate(`/app/friends/${channel.id}`);
},
});
}
+82 -9
View File
@@ -56,7 +56,10 @@
"message.edit_window_expired": "This message can no longer be edited: more than 24 hours passed.", "message.edit_window_expired": "This message can no longer be edited: more than 24 hours passed.",
"file.too_large": "The file exceeds the allowed size.", "file.too_large": "The file exceeds the allowed size.",
"request.bad": "Malformed request.", "request.bad": "Malformed request.",
"file.not_found": "File not found." "file.not_found": "File not found.",
"invite.expired": "This invite has expired.",
"invite.exhausted": "This invite has no uses left.",
"user.not_found": "No user with that username was found."
}, },
"theme": { "theme": {
"switchToLight": "Light theme", "switchToLight": "Light theme",
@@ -172,11 +175,8 @@
"limitReached": "You have reached the server limit — remove one of your servers first." "limitReached": "You have reached the server limit — remove one of your servers first."
}, },
"empty": { "empty": {
"title": "No servers yet", "title": "You have no servers yet",
"body": "Join the instance main server or create your own.", "body": "Create your own server or join one with an invite."
"joinMain": "Join the main server",
"joining": "Joining…",
"noMain": "This instance has no main server configured — create your own."
}, },
"leave": "Leave server", "leave": "Leave server",
"leaveConfirm": "Leave the server “{{name}}”?", "leaveConfirm": "Leave the server “{{name}}”?",
@@ -223,6 +223,74 @@
"slowmode": "slowmode {{seconds}}s", "slowmode": "slowmode {{seconds}}s",
"leaveHint": "You can leave the server in server settings → “Danger zone”." "leaveHint": "You can leave the server in server settings → “Danger zone”."
}, },
"friends": {
"rail": "Friends",
"title": "Friends",
"sidebarLabel": "Friends and direct messages",
"sidebarHint": "Direct messages live here — they are not in the server rail.",
"dmSection": "Direct messages",
"dmEmpty": "No direct messages yet.",
"friendsSection": "Friends",
"friendsEmpty": "Your friend list is empty.",
"open": "Open a conversation with {{name}}",
"empty": {
"title": "Add friends to start talking",
"body": "Find someone by username and send a friend request — a direct conversation appears once it is accepted.",
"search": "Find friends"
},
"search": {
"title": "Friend search",
"close": "Close search",
"label": "Search by username",
"placeholder": "Enter a username",
"hint": "Type at least 2 characters.",
"empty": "Nobody found.",
"add": "Add friend",
"adding": "Sending…",
"requested": "Request sent",
"friend": "Already friends",
"message": "Message"
},
"requests": {
"incoming": "Incoming requests",
"outgoing": "Outgoing requests",
"accept": "Accept",
"decline": "Decline",
"cancel": "Cancel"
},
"list": {
"label": "Friend list",
"message": "Message",
"more": "Actions: {{name}}",
"menu": "Actions for {{name}}",
"remove": "Remove friend",
"block": "Block"
},
"lastSeen": {
"label": "Last seen",
"online": "Online now",
"unknown": "Offline for a while",
"at": "Last seen: {{time}}"
},
"dm": {
"unknown": "Recipient",
"notFound": {
"title": "Conversation unavailable",
"body": "This direct conversation was not found: it may be deleted or closed to you."
}
}
},
"invites": {
"title": "Join with an invite",
"hint": "Paste a code or a link like https://<domain>/invite/<code>.",
"label": "Invite code or link",
"placeholder": "e.g. ab12cd or https://example.com/invite/ab12cd",
"invalid": "Could not recognise the invite code.",
"join": "Join",
"joining": "Joining…",
"members": "Members: {{count}}",
"alreadyMember": "You are already on this server"
},
"chat": { "chat": {
"log": "Message log of {{name}}", "log": "Message log of {{name}}",
"loadMore": "Load more", "loadMore": "Load more",
@@ -236,7 +304,9 @@
"unknownAuthor": "unknown", "unknownAuthor": "unknown",
"start": { "start": {
"title": "This is the start of #{{name}}", "title": "This is the start of #{{name}}",
"body": "There are no messages here yet. Say something — everyone in the channel will see it." "body": "There are no messages here yet. Say something — everyone in the channel will see it.",
"directTitle": "This is the beginning of your conversation with {{name}}",
"directBody": "No messages here yet. Say hello first."
}, },
"message": { "message": {
"by": "Message from {{name}} in {{channel}}", "by": "Message from {{name}} in {{channel}}",
@@ -290,7 +360,9 @@
"tooLarge": "File “{{name}}” exceeds the {{limit}} limit", "tooLarge": "File “{{name}}” exceeds the {{limit}} limit",
"rateLimited": "Too many messages. You can send again in {{seconds}} s.", "rateLimited": "Too many messages. You can send again in {{seconds}} s.",
"denied": "You are not allowed to send messages in this channel.", "denied": "You are not allowed to send messages in this channel.",
"readOnly": "This channel is read-only for you." "readOnly": "This channel is read-only for you.",
"directLabel": "Message to {{name}}",
"directPlaceholder": "Message {{name}}"
}, },
"typing_one": "{{names}} is typing…", "typing_one": "{{names}} is typing…",
"typing_few": "{{names}} are typing…", "typing_few": "{{names}} are typing…",
@@ -315,7 +387,8 @@
"spoiler": { "spoiler": {
"show": "Reveal spoiler", "show": "Reveal spoiler",
"hide": "Hide spoiler" "hide": "Hide spoiler"
} },
"logDirect": "Direct conversation with {{name}}"
}, },
"user": { "user": {
"panelLabel": "User panel", "panelLabel": "User panel",
+82 -9
View File
@@ -56,7 +56,10 @@
"message.edit_window_expired": "Сообщение нельзя изменить: прошло больше 24 часов.", "message.edit_window_expired": "Сообщение нельзя изменить: прошло больше 24 часов.",
"file.too_large": "Файл больше допустимого размера.", "file.too_large": "Файл больше допустимого размера.",
"request.bad": "Некорректный запрос.", "request.bad": "Некорректный запрос.",
"file.not_found": "Файл не найден." "file.not_found": "Файл не найден.",
"invite.expired": "Срок действия приглашения истёк.",
"invite.exhausted": "У приглашения больше не осталось использований.",
"user.not_found": "Пользователь с таким логином не найден."
}, },
"theme": { "theme": {
"switchToLight": "Светлая тема", "switchToLight": "Светлая тема",
@@ -172,11 +175,8 @@
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих." "limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих."
}, },
"empty": { "empty": {
"title": "Пока нет ни одного сервера", "title": "У вас пока нет серверов",
"body": "Присоединитесь к главному серверу инстанса или создайте свой.", "body": "Создайте свой сервер или присоединитесь по приглашению."
"joinMain": "Присоединиться к главному серверу",
"joining": "Присоединяемся…",
"noMain": "Главный сервер на инстансе не настроен — создайте свой."
}, },
"leave": "Покинуть сервер", "leave": "Покинуть сервер",
"leaveConfirm": "Покинуть сервер «{{name}}»?", "leaveConfirm": "Покинуть сервер «{{name}}»?",
@@ -223,6 +223,74 @@
"slowmode": "медленный режим {{seconds}} с", "slowmode": "медленный режим {{seconds}} с",
"leaveHint": "Выйти из сервера можно в настройках сервера → «Опасная зона»." "leaveHint": "Выйти из сервера можно в настройках сервера → «Опасная зона»."
}, },
"friends": {
"rail": "Друзья",
"title": "Друзья",
"sidebarLabel": "Друзья и личные беседы",
"sidebarHint": "Личные беседы живут здесь — в рейке серверов их нет.",
"dmSection": "Личные беседы",
"dmEmpty": "Личных бесед пока нет.",
"friendsSection": "Друзья",
"friendsEmpty": "Список друзей пуст.",
"open": "Открыть беседу с {{name}}",
"empty": {
"title": "Добавь друзей для общения",
"body": "Найдите человека по логину и отправьте заявку в друзья — после принятия появится личная беседа.",
"search": "Найти друзей"
},
"search": {
"title": "Поиск друзей",
"close": "Закрыть поиск",
"label": "Поиск по логину",
"placeholder": "Введите логин",
"hint": "Введите минимум 2 символа.",
"empty": "Никого не найдено.",
"add": "Добавить в друзья",
"adding": "Отправляем…",
"requested": "Заявка отправлена",
"friend": "Уже в друзьях",
"message": "Написать"
},
"requests": {
"incoming": "Входящие заявки",
"outgoing": "Исходящие заявки",
"accept": "Принять",
"decline": "Отклонить",
"cancel": "Отменить"
},
"list": {
"label": "Список друзей",
"message": "Написать",
"more": "Действия: {{name}}",
"menu": "Действия с {{name}}",
"remove": "Удалить из друзей",
"block": "Заблокировать"
},
"lastSeen": {
"label": "Последний вход",
"online": "Сейчас в сети",
"unknown": "Не в сети давно",
"at": "Был(а) в сети: {{time}}"
},
"dm": {
"unknown": "Собеседник",
"notFound": {
"title": "Беседа недоступна",
"body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт."
}
}
},
"invites": {
"title": "Присоединиться по приглашению",
"hint": "Вставьте код или ссылку вида https://<домен>/invite/<код>.",
"label": "Код или ссылка приглашения",
"placeholder": "например, ab12cd или https://дом/invite/ab12cd",
"invalid": "Не удалось распознать код приглашения.",
"join": "Присоединиться",
"joining": "Присоединяемся…",
"members": "Участников: {{count}}",
"alreadyMember": "Вы уже на этом сервере"
},
"chat": { "chat": {
"log": "Лента сообщений комнаты {{name}}", "log": "Лента сообщений комнаты {{name}}",
"loadMore": "Загрузить ещё", "loadMore": "Загрузить ещё",
@@ -236,7 +304,9 @@
"unknownAuthor": "неизвестный", "unknownAuthor": "неизвестный",
"start": { "start": {
"title": "Это начало комнаты #{{name}}", "title": "Это начало комнаты #{{name}}",
"body": "Здесь пока нет сообщений. Напишите первым — сообщение увидят все участники комнаты." "body": "Здесь пока нет сообщений. Напишите первым — сообщение увидят все участники комнаты.",
"directTitle": "Это начало личной беседы с {{name}}",
"directBody": "Здесь пока нет сообщений. Напишите первым."
}, },
"message": { "message": {
"by": "Сообщение от {{name}} в комнате {{channel}}", "by": "Сообщение от {{name}} в комнате {{channel}}",
@@ -290,7 +360,9 @@
"tooLarge": "Файл «{{name}}» больше допустимых {{limit}}", "tooLarge": "Файл «{{name}}» больше допустимых {{limit}}",
"rateLimited": "Слишком часто. Отправить можно будет через {{seconds}} с.", "rateLimited": "Слишком часто. Отправить можно будет через {{seconds}} с.",
"denied": "Нет права отправлять сообщения в эту комнату.", "denied": "Нет права отправлять сообщения в эту комнату.",
"readOnly": "В эту комнату нельзя писать." "readOnly": "В эту комнату нельзя писать.",
"directLabel": "Сообщение для {{name}}",
"directPlaceholder": "Написать {{name}}"
}, },
"typing_one": "{{names}} печатает…", "typing_one": "{{names}} печатает…",
"typing_few": "{{names}} печатают…", "typing_few": "{{names}} печатают…",
@@ -315,7 +387,8 @@
"spoiler": { "spoiler": {
"show": "Показать спойлер", "show": "Показать спойлер",
"hide": "Скрыть спойлер" "hide": "Скрыть спойлер"
} },
"logDirect": "Личная беседа с {{name}}"
}, },
"user": { "user": {
"panelLabel": "Панель пользователя", "panelLabel": "Панель пользователя",
+2
View File
@@ -17,6 +17,8 @@
--color-danger: oklch(0.62 0.19 25); --color-danger: oklch(0.62 0.19 25);
--color-success: oklch(0.72 0.15 150); --color-success: oklch(0.72 0.15 150);
--color-warning: oklch(0.79 0.14 85); --color-warning: oklch(0.79 0.14 85);
/* Тёмно-голубой: точка статуса «невидимый» в разделе друзей. */
--color-info: oklch(0.45 0.09 240);
--color-border: oklch(0.35 0.02 265); --color-border: oklch(0.35 0.02 265);
--radius-sm: 6px; --radius-sm: 6px;
+53
View File
@@ -0,0 +1,53 @@
import type { UserStatus } from '@/api/types';
import { DEFAULT_TIMEZONE } from '@/lib/timezones';
/**
* Присутствие в разделе друзей.
*
* Цвета точки статуса отличаются от панели пользователя (там «не беспокоить»
* красный, как в Discord): в списке друзей `dnd` — серый, а `invisible` —
* тёмно-голубой. У других пользователей «невидимка» приходит как `offline`,
* поэтому тёмно-голубой виден только у себя.
*/
export const presenceDotClass: Record<UserStatus, string> = {
online: 'bg-success',
offline: 'bg-danger',
idle: 'bg-warning',
dnd: 'bg-fg-muted',
invisible: 'bg-info',
};
/**
* Время последнего входа в часовом поясе пользователя из настроек
* (`user.timezone`, по умолчанию Москва).
*
* Возвращает `null`, если дата не пришла или не разбирается: вызывающий код
* показывает «Не в сети давно».
*/
export function formatLastSeen(
lastSeenAt: string | undefined,
timezone: string | undefined,
locale: string,
): string | null {
if (lastSeenAt === undefined || lastSeenAt === '') {
return null;
}
const date = new Date(lastSeenAt);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'short',
timeStyle: 'short',
timeZone: timezone === undefined || timezone === '' ? DEFAULT_TIMEZONE : timezone,
}).format(date);
} catch {
// Неизвестный пояс (например, устаревшее значение в настройках) — МСК.
return new Intl.DateTimeFormat(locale, {
dateStyle: 'short',
timeStyle: 'short',
timeZone: DEFAULT_TIMEZONE,
}).format(date);
}
}
+48 -89
View File
@@ -1,62 +1,65 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Outlet, useLocation, useNavigate } from 'react-router'; import { Outlet, useLocation, useNavigate } from 'react-router';
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds'; import { fetchGuild, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users'; import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { instanceQueryKey } from '@/api/instance';
import { GatewayBridge } from '@/components/GatewayBridge'; import { GatewayBridge } from '@/components/GatewayBridge';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useCurrentUser, useInstance } from '@/lib/hooks'; import { useCurrentUser, useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import { ChannelSidebar } from '@/pages/app/ChannelSidebar'; import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
import { CreateGuildModal } from '@/pages/app/CreateGuildModal';
import { FriendsSidebar } from '@/pages/app/FriendsSidebar';
import { GuildRail } from '@/pages/app/GuildRail'; import { GuildRail } from '@/pages/app/GuildRail';
import { UserPanel } from '@/pages/app/UserPanel'; import { UserPanel } from '@/pages/app/UserPanel';
/** /**
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель * Оболочка приложения: рейка серверов, сайдбар комнат (или друзей), контент и
* пользователя. Данные серверов приходят из снапшота шлюза (соединение * панель пользователя. Данные серверов приходят из снапшота шлюза (соединение
* поднимает `GatewayBridge`), а пока шлюз не подключился — подстраховываемся * поднимает `GatewayBridge`), а пока шлюз не подключился — подстраховываемся
* REST-запросом `GET /users/@me/guilds`. * REST-запросом `GET /users/@me/guilds`.
*
* Раздел «Друзья» — такой же раздел первого уровня, как сервер: он занимает
* `/app/friends` и `/app/friends/:channelId`, но не является сервером.
*/ */
export default function AppLayout() { export default function AppLayout() {
const { t } = useTranslation(); const { t } = useTranslation();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const instance = useInstance(); const instance = useInstance();
const guilds = useSessionStore((state) => state.guilds); const guilds = useSessionStore((state) => state.guilds);
const selectedGuildId = useSessionStore((state) => state.selectedGuildId); const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
const selectedChannelId = useSessionStore((state) => state.selectedChannelId);
const selectChannel = useSessionStore((state) => state.selectChannel); const selectChannel = useSessionStore((state) => state.selectChannel);
const upsertGuild = useSessionStore((state) => state.upsertGuild); const upsertGuild = useSessionStore((state) => state.upsertGuild);
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
const [createOpen, setCreateOpen] = useState(false);
const [guildName, setGuildName] = useState('');
const segments = location.pathname.split('/').filter((segment) => segment !== ''); const segments = location.pathname.split('/').filter((segment) => segment !== '');
const guildId = segments[1] === undefined ? null : decodeURIComponent(segments[1]); const isFriends = segments[1] === 'friends';
const channelId = segments[2] === undefined ? null : decodeURIComponent(segments[2]); const guildId = isFriends || segments[1] === undefined ? null : decodeURIComponent(segments[1]);
const channelId = isFriends || segments[2] === undefined ? null : decodeURIComponent(segments[2]);
const dmChannelId =
isFriends && segments[2] !== undefined ? decodeURIComponent(segments[2]) : null;
// URL — источник правды для выбранного сервера и комнаты. // URL — источник правды для выбранного сервера и комнаты.
useEffect(() => { useEffect(() => {
if (guildId === selectedGuildId) { if (guildId === selectedGuildId && channelId === selectedChannelId) {
return; return;
} }
selectChannel(guildId, channelId); selectChannel(guildId, channelId);
}, [guildId, channelId, selectedGuildId, selectChannel]); }, [guildId, channelId, selectedGuildId, selectedChannelId, selectChannel]);
// `/app/empty` — служебный маршрут, а не идентификатор сервера. // `/app/empty` — служебный маршрут, а не идентификатор сервера.
const realGuildId = guildId === 'empty' ? null : guildId; const realGuildId = guildId === 'empty' ? null : guildId;
// Сервер появился (вступили в главный или создали свой) — сразу открываем его. // Сервер появился (создали свой или вступили по приглашению) — открываем его.
// В разделе друзей ничего не переключаем: там свой маршрут.
useEffect(() => { useEffect(() => {
if (realGuildId !== null || guilds.length === 0) { if (isFriends || realGuildId !== null || guilds.length === 0) {
return; return;
} }
const first = guilds[0]; const first = guilds[0];
@@ -67,7 +70,23 @@ export default function AppLayout() {
void navigate(channel === null ? `/app/${first.id}` : `/app/${first.id}/${channel}`, { void navigate(channel === null ? `/app/${first.id}` : `/app/${first.id}/${channel}`, {
replace: true, replace: true,
}); });
}, [realGuildId, guilds, navigate]); }, [isFriends, realGuildId, guilds, navigate]);
// Сервер выбран, а комната ещё нет (например, сразу после вступления по
// приглашению): открываем его общий канал, как только список загрузился.
const selectedGuild = useMemo(
() => (realGuildId === null ? null : (guilds.find((g) => g.id === realGuildId) ?? null)),
[realGuildId, guilds],
);
useEffect(() => {
if (realGuildId === null || channelId !== null || selectedGuild === null) {
return;
}
const channel = useSessionStore.getState().firstChannelId(realGuildId);
if (channel !== null && channel !== channelId) {
void navigate(`/app/${realGuildId}/${channel}`, { replace: true });
}
}, [realGuildId, channelId, selectedGuild, navigate]);
const myGuilds = useQuery({ const myGuilds = useQuery({
queryKey: myGuildsQueryKey, queryKey: myGuildsQueryKey,
@@ -94,7 +113,8 @@ export default function AppLayout() {
}); });
const hydrated = useRef<string | null>(null); const hydrated = useRef<string | null>(null);
// Сервер есть в URL, но отсутствует в снапшоте (например, после RESUME). // Сервер есть в URL, но отсутствует в снапшоте (после RESUME или вступления
// по приглашению).
useEffect(() => { useEffect(() => {
const guild = fallbackGuild.data; const guild = fallbackGuild.data;
if (guild === undefined || hydrated.current === guild.id) { if (guild === undefined || hydrated.current === guild.id) {
@@ -113,30 +133,12 @@ export default function AppLayout() {
}); });
}, [fallbackGuild.data, upsertGuild]); }, [fallbackGuild.data, upsertGuild]);
const create = useMutation({
mutationFn: (name: string) => createGuild(name),
onSuccess: (guild) => {
setCreateOpen(false);
setGuildName('');
upsertGuild(guild);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void navigate(`/app/${guild.id}`);
},
});
const closeCreate = useCallback(() => {
setCreateOpen(false);
}, []);
const guildList = useMemo( const guildList = useMemo(
() => guilds.map((guild) => ({ id: guild.id, name: guild.name })), () => guilds.map((guild) => ({ id: guild.id, name: guild.name })),
[guilds], [guilds],
); );
const canCreate = instance.data?.allow_guild_creation !== false; const canCreate = instance.data?.allow_guild_creation !== false;
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
return ( return (
<div className="flex h-full min-h-full flex-col"> <div className="flex h-full min-h-full flex-col">
@@ -152,9 +154,13 @@ export default function AppLayout() {
guilds={guildList} guilds={guildList}
selectedGuildId={guildId} selectedGuildId={guildId}
canCreate={canCreate} canCreate={canCreate}
onCreate={() => setCreateOpen(true)} onCreate={openGuildCreate}
/> />
{isFriends ? (
<FriendsSidebar channelId={dmChannelId} />
) : (
<ChannelSidebar guildId={guildId} channelId={channelId} /> <ChannelSidebar guildId={guildId} channelId={channelId} />
)}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
{/* {/*
Ключ по пути: смена комнаты/сервера появляется с анимацией. Ключ по пути: смена комнаты/сервера появляется с анимацией.
@@ -168,54 +174,7 @@ export default function AppLayout() {
</div> </div>
</div> </div>
<Modal <CreateGuildModal />
open={createOpen}
title={t('guilds.create.title')}
onClose={closeCreate}
footer={
<>
<Button variant="ghost" onClick={closeCreate}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate(guildName.trim())}
disabled={create.isPending || guildName.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (guildName.trim() !== '') {
create.mutate(guildName.trim());
}
}}
>
<Field
label={t('guilds.create.name')}
placeholder={t('guilds.create.namePlaceholder')}
value={guildName}
onChange={(event) => setGuildName(event.target.value)}
maxLength={64}
hint={t('guilds.create.hint')}
/>
{instance.data === undefined ? null : (
<p className="text-xs text-fg-muted">
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
</p>
)}
{limitReached && !create.isError ? (
<p className="text-xs text-warning" role="status">
{t('guilds.create.limitReached')}
</p>
) : null}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
</Modal>
</div> </div>
); );
} }
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { createGuild } from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
/**
* Единственное модальное окно создания сервера (`POST /guilds {name}`).
* Открывается из рейки и с пустого экрана «У вас пока нет серверов»,
* поэтому смонтировано в `AppLayout`, а состояние — в `ui`-сторе.
*/
export function CreateGuildModal() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const instance = useInstance();
const open = useUiStore((state) => state.guildCreateOpen);
const close = useUiStore((state) => state.closeGuildCreate);
const guilds = useSessionStore((state) => state.guilds);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const [name, setName] = useState('');
const create = useMutation({
mutationFn: (guildName: string) => createGuild(guildName),
onSuccess: (guild) => {
close();
setName('');
upsertGuild(guild);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void navigate(`/app/${guild.id}`);
},
});
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
return (
<Modal
open={open}
title={t('guilds.create.title')}
onClose={close}
footer={
<>
<Button variant="ghost" onClick={close}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate(name.trim())}
disabled={create.isPending || name.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (name.trim() !== '') {
create.mutate(name.trim());
}
}}
>
<Field
label={t('guilds.create.name')}
placeholder={t('guilds.create.namePlaceholder')}
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={64}
hint={t('guilds.create.hint')}
/>
{instance.data === undefined ? null : (
<p className="text-xs text-fg-muted">
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
</p>
)}
{limitReached && !create.isError ? (
<p className="text-xs text-warning" role="status">
{t('guilds.create.limitReached')}
</p>
) : null}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
</Modal>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useParams } from 'react-router';
import { DirectMessageView } from '@/components/friends/DirectMessageView';
import { FriendsView } from '@/components/friends/FriendsView';
/**
* Маршрут раздела «Друзья»: `/app/friends` — список друзей и заявок,
* `/app/friends/:channelId` — открытая личная беседа.
*/
export default function FriendsPage() {
const { channelId } = useParams<{ channelId?: string }>();
if (channelId === undefined) {
return <FriendsView />;
}
return <DirectMessageView channelId={channelId} />;
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router';
import type { Channel, UserStatus } from '@/api/types';
import { useDmUnread } from '@/components/friends/useDmUnread';
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
import { Avatar } from '@/components/ui/Avatar';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { presenceDotClass } from '@/lib/presence';
import { useFriendsStore } from '@/stores/friends';
import { useSessionStore } from '@/stores/session';
/** Точка статуса собеседника: та же форма, что в списке друзей. */
function StatusDot({ status, testId }: { status: UserStatus; testId: string }) {
return (
<span
aria-hidden="true"
data-testid={testId}
data-status={status}
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
/>
);
}
/**
* Ссылка на личную беседу. Отдельный компонент — чтобы подписка на
* непрочитанные жила на каждую беседу, а не на весь сайдбар.
*/
function DirectChannelLink({ channel, selected }: { channel: Channel; selected: boolean }) {
const { t } = useTranslation();
const { unread, mentions, exact } = useDmUnread(channel);
const highlighted = !selected && (unread > 0 || mentions > 0);
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
const label =
mentions > 0
? t('channels.selectMentions', { name, count: mentions })
: unread > 0
? t('channels.selectUnread', { name, count: unread })
: t('friends.open', { name });
return (
<NavLink
to={`/app/friends/${channel.id}`}
aria-current={selected ? 'page' : undefined}
aria-label={label}
title={name}
data-testid={`dm-link-${channel.id}`}
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
selected
? 'gl-indicator bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
} ${highlighted ? 'font-semibold text-fg' : ''}`}
>
<span className="relative shrink-0">
<Avatar
name={name}
seed={channel.recipient_id ?? channel.id}
fileId={channel.icon_file_id}
size="sm"
/>
<StatusDot status={channel.status ?? 'offline'} testId={`dm-status-${channel.id}`} />
</span>
<span className="truncate">{name}</span>
{selected ? null : mentions > 0 ? (
<span
data-testid={`dm-mentions-${channel.id}`}
title={t('chat.mentions', { count: mentions })}
className="ml-auto rounded-full bg-danger px-1.5 text-[10px] font-semibold leading-4 text-white"
>
{mentions}
</span>
) : unread > 0 ? (
<span
data-testid={`dm-unread-${channel.id}`}
title={t('chat.unread', { count: unread })}
className={
exact
? 'ml-auto rounded-full bg-surface-3 px-1.5 text-[10px] font-semibold leading-4 text-fg'
: 'ml-auto h-2 w-2 rounded-full bg-fg'
}
>
{exact ? unread : ''}
</span>
) : null}
</NavLink>
);
}
/** Порядок бесед: свежие переписки выше, остальные — по имени. */
function byRecent(left: Channel, right: Channel): number {
const leftAt = Date.parse(left.last_message_at ?? '');
const rightAt = Date.parse(right.last_message_at ?? '');
if (Number.isFinite(leftAt) && Number.isFinite(rightAt) && leftAt !== rightAt) {
return rightAt - leftAt;
}
return left.name.localeCompare(right.name);
}
/** Порядок друзей: сначала те, кто в сети. */
function byPresence(left: { visible_status: UserStatus }, right: { visible_status: UserStatus }) {
const rank = (status: UserStatus): number => (status === 'offline' ? 1 : 0);
return rank(left.visible_status) - rank(right.visible_status);
}
/**
* Сайдбар раздела «Друзья»: заголовок, список личных бесед и список друзей.
* Личные беседы не попадают в рейку серверов — они живут только здесь.
*/
export function FriendsSidebar({ channelId }: { channelId: string | null }) {
const { t } = useTranslation();
const dmChannels = useSessionStore((state) => state.dmChannels);
const friends = useFriendsStore((state) => state.friends);
const status = useFriendsStore((state) => state.status);
const load = useFriendsStore((state) => state.load);
const openDm = useOpenDirectChannel();
useEffect(() => {
void load();
}, [load]);
const direct = useMemo(() => [...dmChannels].sort(byRecent), [dmChannels]);
const friendsSorted = useMemo(() => [...friends].sort(byPresence), [friends]);
return (
<aside
aria-label={t('friends.sidebarLabel')}
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
>
<header className="flex items-center gap-2 border-b border-border/40 px-3 py-3">
<h1 className="truncate text-sm font-semibold">{t('friends.title')}</h1>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
<section aria-label={t('friends.dmSection')}>
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.dmSection')}
</h2>
{direct.length === 0 ? (
<p className="px-2 text-xs text-fg-muted">{t('friends.dmEmpty')}</p>
) : (
<ul className="gl-stagger flex flex-col gap-0.5" data-testid="dm-list">
{direct.map((channel) => (
<li key={channel.id}>
<DirectChannelLink channel={channel} selected={channel.id === channelId} />
</li>
))}
</ul>
)}
</section>
<section aria-label={t('friends.friendsSection')} className="mt-4">
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.friendsSection')}
</h2>
{status === 'loading' && friends.length === 0 ? (
<SkeletonLines className="px-2" rows={3} />
) : friends.length === 0 ? (
<p className="px-2 text-xs text-fg-muted">{t('friends.friendsEmpty')}</p>
) : (
<ul className="gl-stagger flex flex-col gap-0.5" data-testid="friends-sidebar-list">
{friendsSorted.map((friend) => {
const name = friend.display_name === '' ? friend.username : friend.display_name;
return (
<li key={friend.user_id}>
<button
type="button"
aria-label={t('friends.open', { name })}
title={name}
onClick={() => {
openDm.mutate(friend.user_id);
}}
className="flex w-full items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-left text-sm text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-2 hover:text-fg"
>
<span className="relative shrink-0">
<Avatar
name={name}
seed={friend.user_id}
fileId={friend.avatar_file_id}
size="sm"
/>
<StatusDot
status={friend.visible_status}
testId={`friend-sidebar-status-${friend.user_id}`}
/>
</span>
<span className="truncate">{name}</span>
</button>
</li>
);
})}
</ul>
)}
</section>
</div>
<footer className="border-t border-border/40 px-3 py-2">
<p className="text-xs text-fg-muted">{t('friends.sidebarHint')}</p>
</footer>
</aside>
);
}
+36 -1
View File
@@ -10,7 +10,10 @@ interface GuildRailProps {
onCreate: () => void; onCreate: () => void;
} }
/** Левая рейка: иконки серверов-инициалов и кнопка создания сервера. */ /**
* Левая рейка: первой иконкой — «Друзья», ниже — серверы и кнопка создания.
* Личные беседы в рейку не попадают: они живут внутри раздела друзей.
*/
export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: GuildRailProps) { export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: GuildRailProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -19,6 +22,38 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
aria-label={t('guilds.railLabel')} aria-label={t('guilds.railLabel')}
className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3" className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3"
> >
<NavLink
to="/app/friends"
title={t('friends.rail')}
aria-label={t('friends.rail')}
data-testid="friends-rail-link"
className={({ isActive }) =>
`relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border text-2xl transition-[border-radius,border-color,background-color,color,transform] duration-[var(--duration-fast)] ${
isActive
? 'gl-ring rounded-[var(--radius-lg)] border-accent bg-surface-1 text-accent'
: 'border-border bg-surface-1 text-fg-muted hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] hover:text-fg active:translate-y-0'
}`
}
>
{/* Облачко с двумя собеседниками: узнаваемая иконка личных бесед. */}
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="h-7 w-7"
>
<path d="M4 5.5h16v10H9l-5 4z" />
<circle cx="9.5" cy="10.5" r="1.4" fill="currentColor" stroke="none" />
<circle cx="14.5" cy="10.5" r="1.4" fill="currentColor" stroke="none" />
</svg>
</NavLink>
<div role="separator" aria-hidden="true" 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"> <ul className="gl-stagger flex flex-col items-center gap-2" data-testid="guild-rail">
{guilds.map((guild) => { {guilds.map((guild) => {
const active = guild.id === selectedGuildId; const active = guild.id === selectedGuildId;
+14 -53
View File
@@ -1,72 +1,33 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { joinGuild } from '@/api/guilds'; import { InviteJoin } from '@/components/friends/InviteJoin';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives'; import { Button, Card } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks'; import { useUiStore } from '@/stores/ui';
import { useSessionStore } from '@/stores/session';
/** /**
* Экран пустого списка серверов: присоединиться к главному серверу инстанса * Экран пустого списка серверов. Главного сервера больше нет: новый
* (id берём из `GET /instance`) или создать свой (кнопка есть в рейке). * пользователь видит пустую рейку, может создать свой сервер или
* присоединиться по приглашению. Иконки серверов появятся в рейке сразу
* после вступления.
*/ */
export default function NoGuildView() { export default function NoGuildView() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const openGuildCreate = useUiStore((state) => state.openGuildCreate);
const instance = useInstance();
const guilds = useSessionStore((state) => state.guilds);
const mainGuildId = instance.data?.main_guild_id;
const alreadyMember =
mainGuildId !== undefined && guilds.some((guild) => guild.id === mainGuildId);
const join = useMutation({
mutationFn: (guildId: string) => joinGuild(guildId),
onSuccess: () => {
// Список серверов обновится снапшотом шлюза; REST — подстраховка.
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
},
});
return ( return (
<section id="app-content" className="flex h-full items-center justify-center p-6"> <section id="app-content" className="absolute inset-0 overflow-y-auto p-6">
<Card className="max-w-lg text-center"> <div className="flex min-h-full items-center justify-center">
<Card className="w-full max-w-lg">
<h2 className="text-lg font-semibold">{t('guilds.empty.title')}</h2> <h2 className="text-lg font-semibold">{t('guilds.empty.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p> <p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
{instance.isPending ? ( <Button className="mt-4" onClick={openGuildCreate}>
<p className="mt-4 text-sm text-fg-muted" role="status"> {t('guilds.add')}
{t('common.loading')}
</p>
) : null}
{mainGuildId !== undefined && !alreadyMember ? (
<Button
className="mt-4"
disabled={join.isPending}
onClick={() => join.mutate(mainGuildId)}
>
{t(join.isPending ? 'guilds.empty.joining' : 'guilds.empty.joinMain')}
</Button> </Button>
) : null}
{mainGuildId === undefined && !instance.isPending ? ( <InviteJoin className="mt-6 border-t border-border/60 pt-4" />
<p className="mt-4 text-sm text-fg-muted">{t('guilds.empty.noMain')}</p>
) : null}
{join.isError ? <ErrorNotice className="mt-3" error={join.error} /> : null}
{instance.isError ? (
<ErrorNotice
className="mt-3"
error={instance.error}
onRetry={() => void instance.refetch()}
/>
) : null}
</Card> </Card>
</div>
</section> </section>
); );
} }
+1
View File
@@ -11,6 +11,7 @@ export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
export const StatusPage = lazy(() => import('@/pages/StatusPage')); export const StatusPage = lazy(() => import('@/pages/StatusPage'));
export const AppLayout = lazy(() => import('@/pages/app/AppLayout')); export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
export const GuildView = lazy(() => import('@/pages/app/GuildView')); export const GuildView = lazy(() => import('@/pages/app/GuildView'));
export const FriendsPage = lazy(() => import('@/pages/app/FriendsPage'));
export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView')); export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout')); export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
export const AccountSettingsLayout = lazy(() => import('@/pages/settings/AccountSettingsLayout')); export const AccountSettingsLayout = lazy(() => import('@/pages/settings/AccountSettingsLayout'));
+5
View File
@@ -6,6 +6,7 @@ import {
AppearanceSettingsPage, AppearanceSettingsPage,
AppLayout, AppLayout,
IndexRedirect, IndexRedirect,
FriendsPage,
GuildView, GuildView,
InstanceSettingsPage, InstanceSettingsPage,
LoginPage, LoginPage,
@@ -41,6 +42,10 @@ export const routes = [
children: [ children: [
{ index: true, element: <Navigate to="empty" replace /> }, { index: true, element: <Navigate to="empty" replace /> },
{ path: 'empty', element: <NoGuildView /> }, { path: 'empty', element: <NoGuildView /> },
// Раздел друзей: статические сегменты выше динамических,
// поэтому `friends` не перехватывается маршрутом `:guildId`.
{ path: 'friends', element: <FriendsPage /> },
{ path: 'friends/:channelId', element: <FriendsPage /> },
{ path: ':guildId', element: <GuildView /> }, { path: ':guildId', element: <GuildView /> },
{ path: ':guildId/:channelId', element: <GuildView /> }, { path: ':guildId/:channelId', element: <GuildView /> },
], ],
+122
View File
@@ -0,0 +1,122 @@
import { create } from 'zustand';
import { fetchRelationships, type RelationshipUser, type Relationships } from '@/api/friends';
import type { UserStatus } from '@/api/types';
/**
* Друзья и заявки (AGENT.md §7.8).
*
* Список живёт в Zustand, а не в TanStack Query: он обновляется событиями
* шлюза (`RELATIONSHIP_UPDATE`, `PRESENCE_UPDATE`) точечно, без перезагрузки.
* Личные беседы остаются в сторе сессии (`dmChannels` из снапшота READY),
* непрочитанные считаются по стору сообщений — здесь только связи.
*/
export type FriendsStatus = 'idle' | 'loading' | 'ready' | 'error';
/** Часть `PRESENCE_UPDATE`: сервер присылает только изменившиеся поля. */
export interface PresencePatch {
status?: UserStatus;
visible_status?: UserStatus;
custom_status?: string;
last_seen_at?: string;
}
interface FriendsState {
friends: RelationshipUser[];
incoming: RelationshipUser[];
outgoing: RelationshipUser[];
blocked: RelationshipUser[];
status: FriendsStatus;
error: unknown;
/**
* Загрузить связи. Повторный вызов при `ready` ничего не делает — компоненты
* могут звать его из эффекта, не опасаясь цикла запросов.
*/
load: (options?: { force?: boolean }) => Promise<void>;
setRelationships: (data: Relationships) => void;
/** RELATIONSHIP_UPDATE: перечитать список связей. */
reload: () => Promise<void>;
/** PRESENCE_UPDATE: обновить статус друга без перезагрузки списка. */
applyPresence: (userId: string, patch: PresencePatch) => void;
reset: () => void;
}
const defaults = {
friends: [] as RelationshipUser[],
incoming: [] as RelationshipUser[],
outgoing: [] as RelationshipUser[],
blocked: [] as RelationshipUser[],
status: 'idle' as FriendsStatus,
error: null as unknown,
};
/** Применяет патч присутствия к одной записи списка. */
function withPresence(user: RelationshipUser, patch: PresencePatch): RelationshipUser {
const next: RelationshipUser = { ...user };
if (patch.status !== undefined) {
next.status = patch.status;
}
if (patch.visible_status !== undefined) {
next.visible_status = patch.visible_status;
} else if (patch.status !== undefined) {
// Статус без явного «видимого» считаем видимым как есть.
next.visible_status = patch.status;
}
if (patch.custom_status !== undefined) {
next.custom_status = patch.custom_status;
}
if (patch.last_seen_at !== undefined) {
next.last_seen_at = patch.last_seen_at;
}
return next;
}
export const useFriendsStore = create<FriendsState>((set, get) => ({
...defaults,
load: async (options) => {
const current = get().status;
if (options?.force !== true && (current === 'loading' || current === 'ready')) {
return;
}
set({ status: 'loading', error: null });
try {
const data = await fetchRelationships();
set({ ...data, status: 'ready', error: null });
} catch (error) {
set({ status: 'error', error });
}
},
setRelationships: (data) => {
set({ ...data, status: 'ready', error: null });
},
reload: async () => {
await get().load({ force: true });
},
applyPresence: (userId, patch) => {
const state = get();
const update = (list: RelationshipUser[]): RelationshipUser[] =>
list.map((user) => (user.user_id === userId ? withPresence(user, patch) : user));
set({
friends: update(state.friends),
incoming: update(state.incoming),
outgoing: update(state.outgoing),
blocked: update(state.blocked),
});
},
reset: () => {
set({ ...defaults });
},
}));
/** Друг по id (для шапки личной беседы). */
export function selectFriendById(userId: string | undefined) {
return (state: FriendsState): RelationshipUser | null =>
userId === undefined ? null : (state.friends.find((user) => user.user_id === userId) ?? null);
}
+38 -2
View File
@@ -11,17 +11,21 @@ import {
import { import {
parseChannelDelete, parseChannelDelete,
parseChannelPayload, parseChannelPayload,
parseDmChannelEvent,
parseGuildId, parseGuildId,
parseGuildUpdate, parseGuildUpdate,
parseMessageDelete, parseMessageDelete,
parseMessagePayload, parseMessagePayload,
parsePinsUpdate, parsePinsUpdate,
parsePresenceEvent,
parseReactionEvent, parseReactionEvent,
parseReadStateUpdate, parseReadStateUpdate,
parseRelationshipEvent,
parseRoleEvent, parseRoleEvent,
parseTypingStart, parseTypingStart,
parseUserUpdate, parseUserUpdate,
} from '@/api/gatewayEvents'; } from '@/api/gatewayEvents';
import { useFriendsStore } from '@/stores/friends';
import { useMessagesStore } from '@/stores/messages'; import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
@@ -170,6 +174,11 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
if (channel === null) { if (channel === null) {
return; return;
} }
// Личная беседа живёт вне серверов: у неё нет guild_id.
if (channel.type === 'dm') {
session.upsertDmChannel(channel);
return;
}
const guildId = channel.guild_id ?? session.selectedGuildId; const guildId = channel.guild_id ?? session.selectedGuildId;
if (guildId !== null) { if (guildId !== null) {
session.upsertChannel(guildId, channel); session.upsertChannel(guildId, channel);
@@ -260,9 +269,32 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
} }
return; return;
} }
case 'PRESENCE_UPDATE': case 'PRESENCE_UPDATE': {
// Присутствие других участников в Фазе 1 не отображается. // Статус друга: обновляем список друзей и шапку личной беседы точечно.
const presence = parsePresenceEvent(event.d);
if (presence === null) {
return; return;
}
useFriendsStore.getState().applyPresence(presence.user_id, presence);
session.patchDmPresence(presence.user_id, presence.visible_status ?? presence.status);
return;
}
case 'RELATIONSHIP_UPDATE': {
// Связь появилась или пропала — перечитываем список целиком.
const relationship = parseRelationshipEvent(event.d);
if (relationship !== null) {
void useFriendsStore.getState().reload();
}
return;
}
case 'DM_CHANNEL_CREATE': {
// В событии только channel_id: список бесед забираем по REST.
const direct = parseDmChannelEvent(event.d);
if (direct !== null) {
void session.reloadDmChannels();
}
return;
}
default: default:
// Неизвестные события игнорируем: контракт может расширяться. // Неизвестные события игнорируем: контракт может расширяться.
return; return;
@@ -324,6 +356,10 @@ function syncUrlWithSelection(): void {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
// Раздел друзей не зависит от выбранного сервера — не выдёргиваем из него.
if (window.location.pathname.startsWith('/app/friends')) {
return;
}
const state = useSessionStore.getState(); const state = useSessionStore.getState();
const target = const target =
state.selectedGuildId === null state.selectedGuildId === null
+45 -1
View File
@@ -1,7 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { fetchDirectChannels } from '@/api/friends';
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway'; import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
import type { Channel, GuildSummary, User } from '@/api/types'; import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
/** /**
* Снапшот реального времени (READY/RESUMED) живёт здесь, а не в TanStack * Снапшот реального времени (READY/RESUMED) живёт здесь, а не в TanStack
@@ -41,6 +42,13 @@ interface SessionState {
removeGuild: (guildId: string) => void; removeGuild: (guildId: string) => void;
setGuildChannels: (guildId: string, channels: Channel[]) => void; setGuildChannels: (guildId: string, channels: Channel[]) => void;
upsertChannel: (guildId: string, channel: Channel) => void; upsertChannel: (guildId: string, channel: Channel) => void;
/** Личные беседы: список целиком (READY или `GET /users/@me/channels`). */
setDmChannels: (channels: Channel[]) => void;
upsertDmChannel: (channel: Channel) => void;
/** PRESENCE_UPDATE: статус собеседника в списке бесед и шапке чата. */
patchDmPresence: (userId: string, status: UserStatus | undefined) => void;
/** DM_CHANNEL_CREATE: перечитываем беседы по REST. */
reloadDmChannels: () => Promise<void>;
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */ /** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
removeChannel: (guildId: string, channelId: string) => void; removeChannel: (guildId: string, channelId: string) => void;
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */ /** Первая доступная (видимая) комната сервера — для выбора после удалений. */
@@ -233,6 +241,42 @@ export const useSessionStore = create<SessionState>((set, get) => ({
}); });
}, },
setDmChannels: (channels) => {
set({ dmChannels: channels });
},
upsertDmChannel: (channel) => {
const channels = get().dmChannels;
const index = channels.findIndex((item) => item.id === channel.id);
if (index === -1) {
set({ dmChannels: [...channels, channel] });
return;
}
const next = [...channels];
// Статус собеседника мог обновиться событием — не теряем его.
next[index] = { ...channels[index], ...channel };
set({ dmChannels: next });
},
patchDmPresence: (userId, status) => {
if (status === undefined) {
return;
}
set({
dmChannels: get().dmChannels.map((channel) =>
channel.recipient_id === userId ? { ...channel, status } : channel,
),
});
},
reloadDmChannels: async () => {
try {
set({ dmChannels: await fetchDirectChannels() });
} catch {
// Сеть подвела: список бесед обновится следующим событием шлюза.
}
},
removeChannel: (guildId, channelId) => { removeChannel: (guildId, channelId) => {
const state = get(); const state = get();
const guild = state.guilds.find((item) => item.id === guildId); const guild = state.guilds.find((item) => item.id === guildId);
+12
View File
@@ -32,6 +32,14 @@ interface UiState {
theme: Theme; theme: Theme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleTheme: () => void; toggleTheme: () => void;
/**
* Модальное окно создания сервера. Живёт в сторе, потому что открыть его
* могут из разных мест: рейка серверов и пустой экран «У вас пока нет
* серверов», а само окно смонтировано одно в `AppLayout`.
*/
guildCreateOpen: boolean;
openGuildCreate: () => void;
closeGuildCreate: () => void;
/** Открыто ли модальное окно настроек (открывается из панели пользователя). */ /** Открыто ли модальное окно настроек (открывается из панели пользователя). */
settingsOpen: boolean; settingsOpen: boolean;
settingsSection: SettingsSection; settingsSection: SettingsSection;
@@ -75,6 +83,10 @@ export const useUiStore = create<UiState>((set, get) => ({
get().setTheme(get().theme === 'dark' ? 'light' : 'dark'); get().setTheme(get().theme === 'dark' ? 'light' : 'dark');
}, },
guildCreateOpen: false,
openGuildCreate: () => set({ guildCreateOpen: true }),
closeGuildCreate: () => set({ guildCreateOpen: false }),
settingsOpen: false, settingsOpen: false,
settingsSection: 'profile', settingsSection: 'profile',
settingsProfileTab: 'main', settingsProfileTab: 'main',