Files
glchat/web/src/api/friends.ts
T
grendervill d02ef7527a fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен
только загрузившему. Добавлено назначение файла (`files.purpose`: attachment |
avatar | banner, миграция 00006): аватары и баннеры отдаются любому
авторизованному пользователю, вложения по-прежнему проверяются по правам
комнаты, а непривязанные загрузки остаются приватными.
2026-09-20 01:39:04 +03:00

246 lines
9.2 KiB
TypeScript

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;
}