diff --git a/web/src/api/dmIcon.ts b/web/src/api/dmIcon.ts new file mode 100644 index 0000000..a8b839f --- /dev/null +++ b/web/src/api/dmIcon.ts @@ -0,0 +1,40 @@ +import { request } from './client'; +import { parseChannelPayload } from './friends'; +import type { Channel } from './types'; + +/** + * Иконка групповой беседы (AGENT.md §7.8, D-070). + * + * Иконка — обычная загрузка с назначением `dm_icon` (D-042), поэтому тело + * запроса — multipart, а `request()` уже умеет `FormData`. Менять иконку может + * только владелец беседы; ответ — обновлённая беседа в общем формате + * `dmChannelPayload`, как у переименования. + */ + +function payloadChannel(value: unknown): Channel { + const channel = parseChannelPayload(value); + if (channel === null) { + throw new Error('malformed group channel payload'); + } + return channel; +} + +/** `POST /channels/{id}/icon` — загрузить иконку беседы (владелец). */ +export async function uploadDmIcon(channelId: string, file: File): Promise { + const form = new FormData(); + form.append('file', file); + const payload = await request<{ channel?: unknown }>( + `/channels/${encodeURIComponent(channelId)}/icon`, + { method: 'POST', form }, + ); + return payloadChannel(payload.channel); +} + +/** `DELETE /channels/{id}/icon` — снять иконку беседы (владелец). */ +export async function deleteDmIcon(channelId: string): Promise { + const payload = await request<{ channel?: unknown }>( + `/channels/${encodeURIComponent(channelId)}/icon`, + { method: 'DELETE' }, + ); + return payloadChannel(payload.channel); +} diff --git a/web/src/api/friends.ts b/web/src/api/friends.ts index 5ba8cfd..c8f323a 100644 --- a/web/src/api/friends.ts +++ b/web/src/api/friends.ts @@ -277,12 +277,7 @@ export async function fetchDirectChannels(signal?: AbortSignal): Promise { - const record = asRecord(item); - const channel = parseDirectChannel(item); - // Групповая беседа: имя и состав приходят полями канала, а не recipient. - return record === null || channel === null ? null : withGroupFields(channel, record); - }) + .map((item) => parseChannelPayload(item)) .filter((item): item is Channel => item !== null); } @@ -324,6 +319,15 @@ function withGroupFields(channel: Channel, record: Record): Cha if (ownerId !== undefined) { channel.owner_id = ownerId; } + // Иконка группы — своя загрузка (D-070): у 1:1 в этом поле аватар + // собеседника, поэтому у группы без иконки поле очищается, а не остаётся + // чужим аватаром. + const icon = asString(record['icon_file_id']); + if (icon !== undefined) { + channel.icon_file_id = icon; + } else { + delete channel.icon_file_id; + } if (Array.isArray(record['recipients'])) { channel.recipients = record['recipients'] .map(parseDmRecipient) @@ -332,6 +336,19 @@ function withGroupFields(channel: Channel, record: Record): Cha return channel; } +/** + * Разбирает беседу из ответа API: 1:1 и группа идут общим путём, поэтому + * групповые поля и иконка не теряются (AGENT.md §7.8). + */ +export function parseChannelPayload(value: unknown): Channel | null { + const record = asRecord(value); + const channel = parseDirectChannel(value); + if (record === null || channel === null) { + return null; + } + return withGroupFields(channel, record); +} + /** `POST /users/@me/channels/group` — создать групповую беседу (7.8). */ export async function createGroupDirectChannel( name: string, @@ -341,12 +358,11 @@ export async function createGroupDirectChannel( method: 'POST', body: { name, recipient_ids: recipientIds }, }); - const record = asRecord(payload.channel); - const channel = parseDirectChannel(payload.channel); - if (record === null || channel === null) { + const channel = parseChannelPayload(payload.channel); + if (channel === null) { throw new Error('malformed group channel payload'); } - return withGroupFields(channel, record); + return channel; } /** `PUT /channels/{id}/recipients/{user_id}` — добавить участника в группу. */ @@ -355,12 +371,11 @@ export async function addGroupRecipient(channelId: string, userId: string): Prom `/channels/${channelId}/recipients/${userId}`, { method: 'PUT' }, ); - const record = asRecord(payload.channel); - const channel = parseDirectChannel(payload.channel); - if (record === null || channel === null) { + const channel = parseChannelPayload(payload.channel); + if (channel === null) { throw new Error('malformed group channel payload'); } - return withGroupFields(channel, record); + return channel; } /** `DELETE /channels/{id}/recipients/{user_id}` — выйти или удалить участника. */ @@ -374,12 +389,11 @@ export async function renameGroupDirectChannel(channelId: string, name: string): method: 'PATCH', body: { name }, }); - const record = asRecord(payload.channel); - const channel = parseDirectChannel(payload.channel); - if (record === null || channel === null) { + const channel = parseChannelPayload(payload.channel); + if (channel === null) { throw new Error('malformed group channel payload'); } - return withGroupFields(channel, record); + return channel; } /** `POST /users/@me/channels` — открыть (или создать) беседу с пользователем. */ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 8b41cff..9b4718f 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -103,8 +103,9 @@ export interface Channel { permission_overwrites?: ChannelOverwrite[]; /** * Поля личной беседы (`type: 'dm'`): id собеседника, его аватар и видимый - * статус. В снапшоте READY аватар приходит как `icon_file_id`, в REST-списке - * бесед — внутри `recipient`. + * статус. Аватар собеседника приходит и как `icon_file_id` (READY и + * REST-список бесед), а у группы в этом поле лежит её собственная иконка + * (AGENT.md 7.8, D-070). */ recipient_id?: string; icon_file_id?: string; diff --git a/web/src/components/friends/DirectMessageView.tsx b/web/src/components/friends/DirectMessageView.tsx index a9cc835..e7be389 100644 --- a/web/src/components/friends/DirectMessageView.tsx +++ b/web/src/components/friends/DirectMessageView.tsx @@ -1,14 +1,20 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { deleteDmIcon, uploadDmIcon } from '@/api/dmIcon'; import type { Message } from '@/api/messages'; +import type { Channel } from '@/api/types'; 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 { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Menu, MenuItem } from '@/components/ui/Menu'; import { Card } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; +import { useCurrentUser } from '@/lib/hooks'; import { presenceDotClass } from '@/lib/presence'; import { useMessagesStore } from '@/stores/messages'; import { useSessionStore } from '@/stores/session'; @@ -27,6 +33,38 @@ export function DirectMessageView({ channelId }: { channelId: string }) { ), ); const reloadDmChannels = useSessionStore((state) => state.reloadDmChannels); + const upsertDmChannel = useSessionStore((state) => state.upsertDmChannel); + const currentUser = useCurrentUser(); + + // Иконку групповой беседы меняет только владелец (D-070): у 1:1 своей + // иконки нет — там показывается аватар собеседника. + const isOwner = channel?.is_group === true && channel.owner_id === currentUser.data?.id; + const [iconMenuOpen, setIconMenuOpen] = useState(false); + const [iconError, setIconError] = useState(null); + const iconMenuRef = useRef(null); + const iconInput = useRef(null); + + // Ответ сервера кладём в стор бесед: иконка появляется сразу и у владельца, + // а остальным участникам приходит DM_CHANNEL_UPDATE (AGENT.md 11.6). Список + // перечитываем ещё и запросом: снятая иконка приходит пустым полем, а + // слияние в сторе пустое поле не затирает. + const applyUpdated = (updated: Channel): void => { + setIconError(null); + upsertDmChannel(updated); + void reloadDmChannels(); + }; + + const uploadIcon = useMutation({ + mutationFn: (file: File) => uploadDmIcon(channelId, file), + onSuccess: applyUpdated, + onError: (cause) => setIconError(cause), + }); + + const removeIcon = useMutation({ + mutationFn: () => deleteDmIcon(channelId), + onSuccess: applyUpdated, + onError: (cause) => setIconError(cause), + }); // Прямая ссылка на беседу: список приходит снапшотом, но после перезагрузки // страницы его может ещё не быть — подстраховываемся REST-запросом. @@ -115,6 +153,38 @@ export function DirectMessageView({ channelId }: { channelId: string }) { {t(`user.status.${status}`)} )}
+ {isOwner ? ( + <> + + { + const file = event.target.files?.[0]; + if (file !== undefined) { + uploadIcon.mutate(file); + } + // Иначе повторный выбор того же файла не сработает. + event.target.value = ''; + }} + /> + + ) : null}