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