fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен только загрузившему. Добавлено назначение файла (`files.purpose`: attachment | avatar | banner, миграция 00006): аватары и баннеры отдаются любому авторизованному пользователю, вложения по-прежнему проверяются по правам комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
@@ -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;
|
||||
@@ -130,6 +130,7 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
UploaderID: ¤tUser.ID,
|
||||
Filename: sanitizeFilename(header.Filename),
|
||||
ContentType: contentType,
|
||||
Purpose: "avatar",
|
||||
}, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
writeHumaAPIError(w, err)
|
||||
@@ -295,6 +296,11 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Аватары и баннеры видны всем авторизованным: они показываются в списках
|
||||
// участников и друзей (AGENT.md 7.2).
|
||||
if file.Purpose == "avatar" || file.Purpose == "banner" {
|
||||
return file, nil
|
||||
}
|
||||
if file.ChannelID != nil {
|
||||
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
|
||||
return nil, err
|
||||
@@ -355,6 +361,7 @@ func (s *Server) saveUpload(ctx context.Context, file store.File, content io.Rea
|
||||
SizeBytes: written,
|
||||
StoragePath: path,
|
||||
SHA256: hex.EncodeToString(hasher.Sum(nil)),
|
||||
Purpose: file.Purpose,
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
|
||||
@@ -254,6 +254,25 @@ func TestTimezoneAndAvatarUpdate(t *testing.T) {
|
||||
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)
|
||||
if removed.Code != http.StatusOK {
|
||||
t.Fatalf("delete avatar = %d, body = %s", removed.Code, removed.Body.String())
|
||||
|
||||
+12
-5
@@ -20,6 +20,9 @@ type File struct {
|
||||
Height int
|
||||
StoragePath string
|
||||
SHA256 string
|
||||
// Purpose: attachment (по умолчанию) | avatar | banner — от него зависит
|
||||
// проверка доступа при выдаче файла.
|
||||
Purpose string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -36,23 +39,27 @@ type CreateFileParams struct {
|
||||
Height int
|
||||
StoragePath string
|
||||
SHA256 string
|
||||
Purpose string
|
||||
}
|
||||
|
||||
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 регистрирует загруженный файл.
|
||||
func (s *Store) CreateFile(ctx context.Context, params CreateFileParams) (*File, error) {
|
||||
if params.ID == 0 {
|
||||
params.ID = s.NextID()
|
||||
}
|
||||
if params.Purpose == "" {
|
||||
params.Purpose = "attachment"
|
||||
}
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO files (id, uploader_id, guild_id, channel_id, message_id, filename,
|
||||
content_type, size_bytes, width, height, storage_path, sha256, created_at)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
content_type, size_bytes, width, height, storage_path, sha256, created_at, purpose)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
int64(params.ID), int64(params.UploaderID), nullableID(params.GuildID), nullableID(params.ChannelID),
|
||||
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 {
|
||||
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,
|
||||
&file.ContentType, &file.SizeBytes, &file.Width, &file.Height, &file.StoragePath,
|
||||
&file.SHA256, &createdAt)
|
||||
&file.SHA256, &createdAt, &file.Purpose)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (type !== 'text' && type !== 'voice' && type !== 'category') {
|
||||
if (type !== 'text' && type !== 'voice' && type !== 'category' && type !== 'dm') {
|
||||
return null;
|
||||
}
|
||||
const channel: Channel = {
|
||||
@@ -226,9 +226,94 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
||||
if (typeof record['can_view'] === 'boolean') {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Друзья и личные беседы (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) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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
@@ -8,8 +8,8 @@
|
||||
export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const;
|
||||
export type UserStatus = (typeof userStatuses)[number];
|
||||
|
||||
/** Тип комнаты: текст, голосовой канал или категория. */
|
||||
export type ChannelType = 'text' | 'voice' | 'category';
|
||||
/** Тип комнаты: текст, голосовой канал, категория или личная беседа. */
|
||||
export type ChannelType = 'text' | 'voice' | 'category' | 'dm';
|
||||
|
||||
/** Пользователь в объёме, достаточном для интерфейса (GET /users/{id}). */
|
||||
export interface User {
|
||||
@@ -75,6 +75,16 @@ export interface Channel {
|
||||
can_view?: boolean;
|
||||
can_send?: 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 {
|
||||
|
||||
@@ -97,9 +97,15 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
|
||||
const currentUserId = sessionUser?.id ?? null;
|
||||
const permissions = guild?.my_permissions ?? [];
|
||||
const isAdmin = sessionUser?.is_instance_admin === true;
|
||||
const canManage = canManageMessagesPermission(permissions, isAdmin);
|
||||
const canSend = canSendMessages(permissions, isAdmin, channel.can_send);
|
||||
const canAttach = canSend && (isAdmin || hasPermission(permissions, PERMISSION_ATTACH_FILES));
|
||||
// Личная беседа: прав сервера нет, отправку разрешает сам канал (`can_send`),
|
||||
// а модерация сообщений в беседах не применяется.
|
||||
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 windowActive = useWindowActive();
|
||||
|
||||
@@ -189,6 +195,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
|
||||
<MessageList
|
||||
channelId={channelId}
|
||||
channelName={channel.name}
|
||||
direct={isDirect}
|
||||
messages={messages}
|
||||
status={record.status}
|
||||
error={record.error}
|
||||
@@ -212,6 +219,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
|
||||
<Composer
|
||||
channel={channel}
|
||||
authors={authors}
|
||||
direct={isDirect}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
disabledReason={disabledReason}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useMessagesStore } from '@/stores/messages';
|
||||
interface ComposerProps {
|
||||
channel: Channel;
|
||||
authors: AuthorDirectory;
|
||||
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
|
||||
direct?: boolean;
|
||||
/** Сообщение, на которое отвечаем (плашка над полем ввода). */
|
||||
replyTo: Message | null;
|
||||
onCancelReply: () => void;
|
||||
@@ -34,6 +36,7 @@ const LINE_HEIGHT_PX = 22;
|
||||
export function Composer({
|
||||
channel,
|
||||
authors,
|
||||
direct = false,
|
||||
replyTo,
|
||||
onCancelReply,
|
||||
disabledReason,
|
||||
@@ -291,8 +294,12 @@ export function Composer({
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
aria-label={t('chat.composer.label', { name: channel.name })}
|
||||
placeholder={t('chat.composer.placeholder', { name: channel.name })}
|
||||
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
|
||||
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"
|
||||
rows={1}
|
||||
value={value}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
interface MessageListProps {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
/** Личная беседа: тексты без «#» и без упоминания комнаты. */
|
||||
direct?: boolean;
|
||||
/** Лента с оптимистичными сообщениями в конце. */
|
||||
messages: Message[];
|
||||
status: 'idle' | 'loading' | 'ready' | 'error';
|
||||
@@ -48,6 +50,7 @@ const STICK_THRESHOLD_PX = 120;
|
||||
export function MessageList({
|
||||
channelId,
|
||||
channelName,
|
||||
direct = false,
|
||||
messages,
|
||||
status,
|
||||
error,
|
||||
@@ -140,7 +143,9 @@ export function MessageList({
|
||||
ref={containerRef}
|
||||
onScroll={onScroll}
|
||||
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"
|
||||
className="min-h-0 flex-1 overflow-y-auto px-3 py-3"
|
||||
>
|
||||
@@ -159,9 +164,13 @@ export function MessageList({
|
||||
{messages.length === 0 ? (
|
||||
<Card className="mx-auto mt-6 max-w-md text-center" data-testid="chat-start">
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<ol className="flex flex-col">
|
||||
|
||||
@@ -63,10 +63,14 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
}, [membersData]);
|
||||
|
||||
const unique = useMemo(() => [...new Set(userIds)].filter((id) => id !== ''), [userIds]);
|
||||
const missing = useMemo(
|
||||
() => (membersLoaded ? unique.filter((id) => !memberMap.has(id)) : []),
|
||||
[unique, memberMap, membersLoaded],
|
||||
);
|
||||
// В личной беседе участников сервера нет: имена всех авторов берём из
|
||||
// `GET /users/{id}`. В комнате — только те, кого нет в списке участников.
|
||||
const missing = useMemo(() => {
|
||||
if (guildId === null) {
|
||||
return unique;
|
||||
}
|
||||
return membersLoaded ? unique.filter((id) => !memberMap.has(id)) : [];
|
||||
}, [guildId, unique, memberMap, membersLoaded]);
|
||||
|
||||
const users = useQueries({
|
||||
queries: missing.map((userId) => ({
|
||||
@@ -99,6 +103,24 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
return map;
|
||||
}, [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>(() => {
|
||||
const resolve = (userId: string | undefined): ChatAuthor => {
|
||||
if (userId === undefined || userId === '') {
|
||||
@@ -126,7 +148,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
return {
|
||||
id: userId,
|
||||
name: fallbackName ?? t('chat.unknownAuthor'),
|
||||
avatarFileId: undefined,
|
||||
avatarFileId: userAvatarMap.get(userId),
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: false,
|
||||
@@ -141,5 +163,5 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
||||
},
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -56,7 +56,10 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"switchToLight": "Light theme",
|
||||
@@ -172,11 +175,8 @@
|
||||
"limitReached": "You have reached the server limit — remove one of your servers first."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No servers yet",
|
||||
"body": "Join the instance main server or create your own.",
|
||||
"joinMain": "Join the main server",
|
||||
"joining": "Joining…",
|
||||
"noMain": "This instance has no main server configured — create your own."
|
||||
"title": "You have no servers yet",
|
||||
"body": "Create your own server or join one with an invite."
|
||||
},
|
||||
"leave": "Leave server",
|
||||
"leaveConfirm": "Leave the server “{{name}}”?",
|
||||
@@ -223,6 +223,74 @@
|
||||
"slowmode": "slowmode {{seconds}}s",
|
||||
"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": {
|
||||
"log": "Message log of {{name}}",
|
||||
"loadMore": "Load more",
|
||||
@@ -236,7 +304,9 @@
|
||||
"unknownAuthor": "unknown",
|
||||
"start": {
|
||||
"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": {
|
||||
"by": "Message from {{name}} in {{channel}}",
|
||||
@@ -290,7 +360,9 @@
|
||||
"tooLarge": "File “{{name}}” exceeds the {{limit}} limit",
|
||||
"rateLimited": "Too many messages. You can send again in {{seconds}} s.",
|
||||
"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_few": "{{names}} are typing…",
|
||||
@@ -315,7 +387,8 @@
|
||||
"spoiler": {
|
||||
"show": "Reveal spoiler",
|
||||
"hide": "Hide spoiler"
|
||||
}
|
||||
},
|
||||
"logDirect": "Direct conversation with {{name}}"
|
||||
},
|
||||
"user": {
|
||||
"panelLabel": "User panel",
|
||||
|
||||
@@ -56,7 +56,10 @@
|
||||
"message.edit_window_expired": "Сообщение нельзя изменить: прошло больше 24 часов.",
|
||||
"file.too_large": "Файл больше допустимого размера.",
|
||||
"request.bad": "Некорректный запрос.",
|
||||
"file.not_found": "Файл не найден."
|
||||
"file.not_found": "Файл не найден.",
|
||||
"invite.expired": "Срок действия приглашения истёк.",
|
||||
"invite.exhausted": "У приглашения больше не осталось использований.",
|
||||
"user.not_found": "Пользователь с таким логином не найден."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Светлая тема",
|
||||
@@ -172,11 +175,8 @@
|
||||
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Пока нет ни одного сервера",
|
||||
"body": "Присоединитесь к главному серверу инстанса или создайте свой.",
|
||||
"joinMain": "Присоединиться к главному серверу",
|
||||
"joining": "Присоединяемся…",
|
||||
"noMain": "Главный сервер на инстансе не настроен — создайте свой."
|
||||
"title": "У вас пока нет серверов",
|
||||
"body": "Создайте свой сервер или присоединитесь по приглашению."
|
||||
},
|
||||
"leave": "Покинуть сервер",
|
||||
"leaveConfirm": "Покинуть сервер «{{name}}»?",
|
||||
@@ -223,6 +223,74 @@
|
||||
"slowmode": "медленный режим {{seconds}} с",
|
||||
"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": {
|
||||
"log": "Лента сообщений комнаты {{name}}",
|
||||
"loadMore": "Загрузить ещё",
|
||||
@@ -236,7 +304,9 @@
|
||||
"unknownAuthor": "неизвестный",
|
||||
"start": {
|
||||
"title": "Это начало комнаты #{{name}}",
|
||||
"body": "Здесь пока нет сообщений. Напишите первым — сообщение увидят все участники комнаты."
|
||||
"body": "Здесь пока нет сообщений. Напишите первым — сообщение увидят все участники комнаты.",
|
||||
"directTitle": "Это начало личной беседы с {{name}}",
|
||||
"directBody": "Здесь пока нет сообщений. Напишите первым."
|
||||
},
|
||||
"message": {
|
||||
"by": "Сообщение от {{name}} в комнате {{channel}}",
|
||||
@@ -290,7 +360,9 @@
|
||||
"tooLarge": "Файл «{{name}}» больше допустимых {{limit}}",
|
||||
"rateLimited": "Слишком часто. Отправить можно будет через {{seconds}} с.",
|
||||
"denied": "Нет права отправлять сообщения в эту комнату.",
|
||||
"readOnly": "В эту комнату нельзя писать."
|
||||
"readOnly": "В эту комнату нельзя писать.",
|
||||
"directLabel": "Сообщение для {{name}}",
|
||||
"directPlaceholder": "Написать {{name}}"
|
||||
},
|
||||
"typing_one": "{{names}} печатает…",
|
||||
"typing_few": "{{names}} печатают…",
|
||||
@@ -315,7 +387,8 @@
|
||||
"spoiler": {
|
||||
"show": "Показать спойлер",
|
||||
"hide": "Скрыть спойлер"
|
||||
}
|
||||
},
|
||||
"logDirect": "Личная беседа с {{name}}"
|
||||
},
|
||||
"user": {
|
||||
"panelLabel": "Панель пользователя",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
--color-danger: oklch(0.62 0.19 25);
|
||||
--color-success: oklch(0.72 0.15 150);
|
||||
--color-warning: oklch(0.79 0.14 85);
|
||||
/* Тёмно-голубой: точка статуса «невидимый» в разделе друзей. */
|
||||
--color-info: oklch(0.45 0.09 240);
|
||||
--color-border: oklch(0.35 0.02 265);
|
||||
|
||||
--radius-sm: 6px;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,65 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { instanceQueryKey } from '@/api/instance';
|
||||
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 { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
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 { UserPanel } from '@/pages/app/UserPanel';
|
||||
|
||||
/**
|
||||
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
|
||||
* пользователя. Данные серверов приходят из снапшота шлюза (соединение
|
||||
* Оболочка приложения: рейка серверов, сайдбар комнат (или друзей), контент и
|
||||
* панель пользователя. Данные серверов приходят из снапшота шлюза (соединение
|
||||
* поднимает `GatewayBridge`), а пока шлюз не подключился — подстраховываемся
|
||||
* REST-запросом `GET /users/@me/guilds`.
|
||||
*
|
||||
* Раздел «Друзья» — такой же раздел первого уровня, как сервер: он занимает
|
||||
* `/app/friends` и `/app/friends/:channelId`, но не является сервером.
|
||||
*/
|
||||
export default function AppLayout() {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const instance = useInstance();
|
||||
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
|
||||
const selectedChannelId = useSessionStore((state) => state.selectedChannelId);
|
||||
const selectChannel = useSessionStore((state) => state.selectChannel);
|
||||
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [guildName, setGuildName] = useState('');
|
||||
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
|
||||
|
||||
const segments = location.pathname.split('/').filter((segment) => segment !== '');
|
||||
const guildId = segments[1] === undefined ? null : decodeURIComponent(segments[1]);
|
||||
const channelId = segments[2] === undefined ? null : decodeURIComponent(segments[2]);
|
||||
const isFriends = segments[1] === 'friends';
|
||||
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 — источник правды для выбранного сервера и комнаты.
|
||||
useEffect(() => {
|
||||
if (guildId === selectedGuildId) {
|
||||
if (guildId === selectedGuildId && channelId === selectedChannelId) {
|
||||
return;
|
||||
}
|
||||
selectChannel(guildId, channelId);
|
||||
}, [guildId, channelId, selectedGuildId, selectChannel]);
|
||||
}, [guildId, channelId, selectedGuildId, selectedChannelId, selectChannel]);
|
||||
|
||||
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
|
||||
const realGuildId = guildId === 'empty' ? null : guildId;
|
||||
|
||||
// Сервер появился (вступили в главный или создали свой) — сразу открываем его.
|
||||
// Сервер появился (создали свой или вступили по приглашению) — открываем его.
|
||||
// В разделе друзей ничего не переключаем: там свой маршрут.
|
||||
useEffect(() => {
|
||||
if (realGuildId !== null || guilds.length === 0) {
|
||||
if (isFriends || realGuildId !== null || guilds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const first = guilds[0];
|
||||
@@ -67,7 +70,23 @@ export default function AppLayout() {
|
||||
void navigate(channel === null ? `/app/${first.id}` : `/app/${first.id}/${channel}`, {
|
||||
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({
|
||||
queryKey: myGuildsQueryKey,
|
||||
@@ -94,7 +113,8 @@ export default function AppLayout() {
|
||||
});
|
||||
const hydrated = useRef<string | null>(null);
|
||||
|
||||
// Сервер есть в URL, но отсутствует в снапшоте (например, после RESUME).
|
||||
// Сервер есть в URL, но отсутствует в снапшоте (после RESUME или вступления
|
||||
// по приглашению).
|
||||
useEffect(() => {
|
||||
const guild = fallbackGuild.data;
|
||||
if (guild === undefined || hydrated.current === guild.id) {
|
||||
@@ -113,30 +133,12 @@ export default function AppLayout() {
|
||||
});
|
||||
}, [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(
|
||||
() => guilds.map((guild) => ({ id: guild.id, name: guild.name })),
|
||||
[guilds],
|
||||
);
|
||||
|
||||
const canCreate = instance.data?.allow_guild_creation !== false;
|
||||
const limitReached =
|
||||
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-full flex-col">
|
||||
@@ -152,9 +154,13 @@ export default function AppLayout() {
|
||||
guilds={guildList}
|
||||
selectedGuildId={guildId}
|
||||
canCreate={canCreate}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onCreate={openGuildCreate}
|
||||
/>
|
||||
{isFriends ? (
|
||||
<FriendsSidebar channelId={dmChannelId} />
|
||||
) : (
|
||||
<ChannelSidebar guildId={guildId} channelId={channelId} />
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/*
|
||||
Ключ по пути: смена комнаты/сервера появляется с анимацией.
|
||||
@@ -168,54 +174,7 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
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>
|
||||
<CreateGuildModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,10 @@ interface GuildRailProps {
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
/** Левая рейка: иконки серверов-инициалов и кнопка создания сервера. */
|
||||
/**
|
||||
* Левая рейка: первой иконкой — «Друзья», ниже — серверы и кнопка создания.
|
||||
* Личные беседы в рейку не попадают: они живут внутри раздела друзей.
|
||||
*/
|
||||
export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: GuildRailProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -19,6 +22,38 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{guilds.map((guild) => {
|
||||
const active = guild.id === selectedGuildId;
|
||||
|
||||
@@ -1,72 +1,33 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { joinGuild } from '@/api/guilds';
|
||||
import { instanceQueryKey } from '@/api/instance';
|
||||
import { myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { InviteJoin } from '@/components/friends/InviteJoin';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { useInstance } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
|
||||
/**
|
||||
* Экран пустого списка серверов: присоединиться к главному серверу инстанса
|
||||
* (id берём из `GET /instance`) или создать свой (кнопка есть в рейке).
|
||||
* Экран пустого списка серверов. Главного сервера больше нет: новый
|
||||
* пользователь видит пустую рейку, может создать свой сервер или
|
||||
* присоединиться по приглашению. Иконки серверов появятся в рейке сразу
|
||||
* после вступления.
|
||||
*/
|
||||
export default function NoGuildView() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
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 });
|
||||
},
|
||||
});
|
||||
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
|
||||
|
||||
return (
|
||||
<section id="app-content" className="flex h-full items-center justify-center p-6">
|
||||
<Card className="max-w-lg text-center">
|
||||
<section id="app-content" className="absolute inset-0 overflow-y-auto p-6">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
|
||||
|
||||
{instance.isPending ? (
|
||||
<p className="mt-4 text-sm text-fg-muted" role="status">
|
||||
{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 className="mt-4" onClick={openGuildCreate}>
|
||||
{t('guilds.add')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{mainGuildId === undefined && !instance.isPending ? (
|
||||
<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}
|
||||
<InviteJoin className="mt-6 border-t border-border/60 pt-4" />
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
|
||||
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
|
||||
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 SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
|
||||
export const AccountSettingsLayout = lazy(() => import('@/pages/settings/AccountSettingsLayout'));
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppearanceSettingsPage,
|
||||
AppLayout,
|
||||
IndexRedirect,
|
||||
FriendsPage,
|
||||
GuildView,
|
||||
InstanceSettingsPage,
|
||||
LoginPage,
|
||||
@@ -41,6 +42,10 @@ export const routes = [
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="empty" replace /> },
|
||||
{ path: 'empty', element: <NoGuildView /> },
|
||||
// Раздел друзей: статические сегменты выше динамических,
|
||||
// поэтому `friends` не перехватывается маршрутом `:guildId`.
|
||||
{ path: 'friends', element: <FriendsPage /> },
|
||||
{ path: 'friends/:channelId', element: <FriendsPage /> },
|
||||
{ path: ':guildId', element: <GuildView /> },
|
||||
{ path: ':guildId/:channelId', element: <GuildView /> },
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -11,17 +11,21 @@ import {
|
||||
import {
|
||||
parseChannelDelete,
|
||||
parseChannelPayload,
|
||||
parseDmChannelEvent,
|
||||
parseGuildId,
|
||||
parseGuildUpdate,
|
||||
parseMessageDelete,
|
||||
parseMessagePayload,
|
||||
parsePinsUpdate,
|
||||
parsePresenceEvent,
|
||||
parseReactionEvent,
|
||||
parseReadStateUpdate,
|
||||
parseRelationshipEvent,
|
||||
parseRoleEvent,
|
||||
parseTypingStart,
|
||||
parseUserUpdate,
|
||||
} from '@/api/gatewayEvents';
|
||||
import { useFriendsStore } from '@/stores/friends';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
@@ -170,6 +174,11 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
if (channel === null) {
|
||||
return;
|
||||
}
|
||||
// Личная беседа живёт вне серверов: у неё нет guild_id.
|
||||
if (channel.type === 'dm') {
|
||||
session.upsertDmChannel(channel);
|
||||
return;
|
||||
}
|
||||
const guildId = channel.guild_id ?? session.selectedGuildId;
|
||||
if (guildId !== null) {
|
||||
session.upsertChannel(guildId, channel);
|
||||
@@ -260,9 +269,32 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'PRESENCE_UPDATE':
|
||||
// Присутствие других участников в Фазе 1 не отображается.
|
||||
case 'PRESENCE_UPDATE': {
|
||||
// Статус друга: обновляем список друзей и шапку личной беседы точечно.
|
||||
const presence = parsePresenceEvent(event.d);
|
||||
if (presence === null) {
|
||||
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:
|
||||
// Неизвестные события игнорируем: контракт может расширяться.
|
||||
return;
|
||||
@@ -324,6 +356,10 @@ function syncUrlWithSelection(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
// Раздел друзей не зависит от выбранного сервера — не выдёргиваем из него.
|
||||
if (window.location.pathname.startsWith('/app/friends')) {
|
||||
return;
|
||||
}
|
||||
const state = useSessionStore.getState();
|
||||
const target =
|
||||
state.selectedGuildId === null
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { fetchDirectChannels } from '@/api/friends';
|
||||
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
|
||||
@@ -41,6 +42,13 @@ interface SessionState {
|
||||
removeGuild: (guildId: string) => void;
|
||||
setGuildChannels: (guildId: string, channels: 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: удаляет комнату и переносит выбор на первую доступную. */
|
||||
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) => {
|
||||
const state = get();
|
||||
const guild = state.guilds.find((item) => item.id === guildId);
|
||||
|
||||
@@ -32,6 +32,14 @@ interface UiState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
/**
|
||||
* Модальное окно создания сервера. Живёт в сторе, потому что открыть его
|
||||
* могут из разных мест: рейка серверов и пустой экран «У вас пока нет
|
||||
* серверов», а само окно смонтировано одно в `AppLayout`.
|
||||
*/
|
||||
guildCreateOpen: boolean;
|
||||
openGuildCreate: () => void;
|
||||
closeGuildCreate: () => void;
|
||||
/** Открыто ли модальное окно настроек (открывается из панели пользователя). */
|
||||
settingsOpen: boolean;
|
||||
settingsSection: SettingsSection;
|
||||
@@ -75,6 +83,10 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
get().setTheme(get().theme === 'dark' ? 'light' : 'dark');
|
||||
},
|
||||
|
||||
guildCreateOpen: false,
|
||||
openGuildCreate: () => set({ guildCreateOpen: true }),
|
||||
closeGuildCreate: () => set({ guildCreateOpen: false }),
|
||||
|
||||
settingsOpen: false,
|
||||
settingsSection: 'profile',
|
||||
settingsProfileTab: 'main',
|
||||
|
||||
Reference in New Issue
Block a user