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

108 lines
3.7 KiB
TypeScript

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