fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен только загрузившему. Добавлено назначение файла (`files.purpose`: attachment | avatar | banner, миграция 00006): аватары и баннеры отдаются любому авторизованному пользователю, вложения по-прежнему проверяются по правам комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { fetchRelationships, type RelationshipUser, type Relationships } from '@/api/friends';
|
||||
import type { UserStatus } from '@/api/types';
|
||||
|
||||
/**
|
||||
* Друзья и заявки (AGENT.md §7.8).
|
||||
*
|
||||
* Список живёт в Zustand, а не в TanStack Query: он обновляется событиями
|
||||
* шлюза (`RELATIONSHIP_UPDATE`, `PRESENCE_UPDATE`) точечно, без перезагрузки.
|
||||
* Личные беседы остаются в сторе сессии (`dmChannels` из снапшота READY),
|
||||
* непрочитанные считаются по стору сообщений — здесь только связи.
|
||||
*/
|
||||
|
||||
export type FriendsStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
/** Часть `PRESENCE_UPDATE`: сервер присылает только изменившиеся поля. */
|
||||
export interface PresencePatch {
|
||||
status?: UserStatus;
|
||||
visible_status?: UserStatus;
|
||||
custom_status?: string;
|
||||
last_seen_at?: string;
|
||||
}
|
||||
|
||||
interface FriendsState {
|
||||
friends: RelationshipUser[];
|
||||
incoming: RelationshipUser[];
|
||||
outgoing: RelationshipUser[];
|
||||
blocked: RelationshipUser[];
|
||||
status: FriendsStatus;
|
||||
error: unknown;
|
||||
|
||||
/**
|
||||
* Загрузить связи. Повторный вызов при `ready` ничего не делает — компоненты
|
||||
* могут звать его из эффекта, не опасаясь цикла запросов.
|
||||
*/
|
||||
load: (options?: { force?: boolean }) => Promise<void>;
|
||||
setRelationships: (data: Relationships) => void;
|
||||
/** RELATIONSHIP_UPDATE: перечитать список связей. */
|
||||
reload: () => Promise<void>;
|
||||
/** PRESENCE_UPDATE: обновить статус друга без перезагрузки списка. */
|
||||
applyPresence: (userId: string, patch: PresencePatch) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
friends: [] as RelationshipUser[],
|
||||
incoming: [] as RelationshipUser[],
|
||||
outgoing: [] as RelationshipUser[],
|
||||
blocked: [] as RelationshipUser[],
|
||||
status: 'idle' as FriendsStatus,
|
||||
error: null as unknown,
|
||||
};
|
||||
|
||||
/** Применяет патч присутствия к одной записи списка. */
|
||||
function withPresence(user: RelationshipUser, patch: PresencePatch): RelationshipUser {
|
||||
const next: RelationshipUser = { ...user };
|
||||
if (patch.status !== undefined) {
|
||||
next.status = patch.status;
|
||||
}
|
||||
if (patch.visible_status !== undefined) {
|
||||
next.visible_status = patch.visible_status;
|
||||
} else if (patch.status !== undefined) {
|
||||
// Статус без явного «видимого» считаем видимым как есть.
|
||||
next.visible_status = patch.status;
|
||||
}
|
||||
if (patch.custom_status !== undefined) {
|
||||
next.custom_status = patch.custom_status;
|
||||
}
|
||||
if (patch.last_seen_at !== undefined) {
|
||||
next.last_seen_at = patch.last_seen_at;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export const useFriendsStore = create<FriendsState>((set, get) => ({
|
||||
...defaults,
|
||||
|
||||
load: async (options) => {
|
||||
const current = get().status;
|
||||
if (options?.force !== true && (current === 'loading' || current === 'ready')) {
|
||||
return;
|
||||
}
|
||||
set({ status: 'loading', error: null });
|
||||
try {
|
||||
const data = await fetchRelationships();
|
||||
set({ ...data, status: 'ready', error: null });
|
||||
} catch (error) {
|
||||
set({ status: 'error', error });
|
||||
}
|
||||
},
|
||||
|
||||
setRelationships: (data) => {
|
||||
set({ ...data, status: 'ready', error: null });
|
||||
},
|
||||
|
||||
reload: async () => {
|
||||
await get().load({ force: true });
|
||||
},
|
||||
|
||||
applyPresence: (userId, patch) => {
|
||||
const state = get();
|
||||
const update = (list: RelationshipUser[]): RelationshipUser[] =>
|
||||
list.map((user) => (user.user_id === userId ? withPresence(user, patch) : user));
|
||||
set({
|
||||
friends: update(state.friends),
|
||||
incoming: update(state.incoming),
|
||||
outgoing: update(state.outgoing),
|
||||
blocked: update(state.blocked),
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({ ...defaults });
|
||||
},
|
||||
}));
|
||||
|
||||
/** Друг по id (для шапки личной беседы). */
|
||||
export function selectFriendById(userId: string | undefined) {
|
||||
return (state: FriendsState): RelationshipUser | null =>
|
||||
userId === undefined ? null : (state.friends.find((user) => user.user_id === userId) ?? null);
|
||||
}
|
||||
@@ -11,17 +11,21 @@ import {
|
||||
import {
|
||||
parseChannelDelete,
|
||||
parseChannelPayload,
|
||||
parseDmChannelEvent,
|
||||
parseGuildId,
|
||||
parseGuildUpdate,
|
||||
parseMessageDelete,
|
||||
parseMessagePayload,
|
||||
parsePinsUpdate,
|
||||
parsePresenceEvent,
|
||||
parseReactionEvent,
|
||||
parseReadStateUpdate,
|
||||
parseRelationshipEvent,
|
||||
parseRoleEvent,
|
||||
parseTypingStart,
|
||||
parseUserUpdate,
|
||||
} from '@/api/gatewayEvents';
|
||||
import { useFriendsStore } from '@/stores/friends';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
@@ -170,6 +174,11 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
if (channel === null) {
|
||||
return;
|
||||
}
|
||||
// Личная беседа живёт вне серверов: у неё нет guild_id.
|
||||
if (channel.type === 'dm') {
|
||||
session.upsertDmChannel(channel);
|
||||
return;
|
||||
}
|
||||
const guildId = channel.guild_id ?? session.selectedGuildId;
|
||||
if (guildId !== null) {
|
||||
session.upsertChannel(guildId, channel);
|
||||
@@ -260,9 +269,32 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'PRESENCE_UPDATE':
|
||||
// Присутствие других участников в Фазе 1 не отображается.
|
||||
case 'PRESENCE_UPDATE': {
|
||||
// Статус друга: обновляем список друзей и шапку личной беседы точечно.
|
||||
const presence = parsePresenceEvent(event.d);
|
||||
if (presence === null) {
|
||||
return;
|
||||
}
|
||||
useFriendsStore.getState().applyPresence(presence.user_id, presence);
|
||||
session.patchDmPresence(presence.user_id, presence.visible_status ?? presence.status);
|
||||
return;
|
||||
}
|
||||
case 'RELATIONSHIP_UPDATE': {
|
||||
// Связь появилась или пропала — перечитываем список целиком.
|
||||
const relationship = parseRelationshipEvent(event.d);
|
||||
if (relationship !== null) {
|
||||
void useFriendsStore.getState().reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'DM_CHANNEL_CREATE': {
|
||||
// В событии только channel_id: список бесед забираем по REST.
|
||||
const direct = parseDmChannelEvent(event.d);
|
||||
if (direct !== null) {
|
||||
void session.reloadDmChannels();
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// Неизвестные события игнорируем: контракт может расширяться.
|
||||
return;
|
||||
@@ -324,6 +356,10 @@ function syncUrlWithSelection(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
// Раздел друзей не зависит от выбранного сервера — не выдёргиваем из него.
|
||||
if (window.location.pathname.startsWith('/app/friends')) {
|
||||
return;
|
||||
}
|
||||
const state = useSessionStore.getState();
|
||||
const target =
|
||||
state.selectedGuildId === null
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { fetchDirectChannels } from '@/api/friends';
|
||||
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
|
||||
import type { Channel, GuildSummary, User } from '@/api/types';
|
||||
import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
|
||||
|
||||
/**
|
||||
* Снапшот реального времени (READY/RESUMED) живёт здесь, а не в TanStack
|
||||
@@ -41,6 +42,13 @@ interface SessionState {
|
||||
removeGuild: (guildId: string) => void;
|
||||
setGuildChannels: (guildId: string, channels: Channel[]) => void;
|
||||
upsertChannel: (guildId: string, channel: Channel) => void;
|
||||
/** Личные беседы: список целиком (READY или `GET /users/@me/channels`). */
|
||||
setDmChannels: (channels: Channel[]) => void;
|
||||
upsertDmChannel: (channel: Channel) => void;
|
||||
/** PRESENCE_UPDATE: статус собеседника в списке бесед и шапке чата. */
|
||||
patchDmPresence: (userId: string, status: UserStatus | undefined) => void;
|
||||
/** DM_CHANNEL_CREATE: перечитываем беседы по REST. */
|
||||
reloadDmChannels: () => Promise<void>;
|
||||
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
|
||||
removeChannel: (guildId: string, channelId: string) => void;
|
||||
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */
|
||||
@@ -233,6 +241,42 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
setDmChannels: (channels) => {
|
||||
set({ dmChannels: channels });
|
||||
},
|
||||
|
||||
upsertDmChannel: (channel) => {
|
||||
const channels = get().dmChannels;
|
||||
const index = channels.findIndex((item) => item.id === channel.id);
|
||||
if (index === -1) {
|
||||
set({ dmChannels: [...channels, channel] });
|
||||
return;
|
||||
}
|
||||
const next = [...channels];
|
||||
// Статус собеседника мог обновиться событием — не теряем его.
|
||||
next[index] = { ...channels[index], ...channel };
|
||||
set({ dmChannels: next });
|
||||
},
|
||||
|
||||
patchDmPresence: (userId, status) => {
|
||||
if (status === undefined) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
dmChannels: get().dmChannels.map((channel) =>
|
||||
channel.recipient_id === userId ? { ...channel, status } : channel,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
reloadDmChannels: async () => {
|
||||
try {
|
||||
set({ dmChannels: await fetchDirectChannels() });
|
||||
} catch {
|
||||
// Сеть подвела: список бесед обновится следующим событием шлюза.
|
||||
}
|
||||
},
|
||||
|
||||
removeChannel: (guildId, channelId) => {
|
||||
const state = get();
|
||||
const guild = state.guilds.find((item) => item.id === guildId);
|
||||
|
||||
@@ -32,6 +32,14 @@ interface UiState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
/**
|
||||
* Модальное окно создания сервера. Живёт в сторе, потому что открыть его
|
||||
* могут из разных мест: рейка серверов и пустой экран «У вас пока нет
|
||||
* серверов», а само окно смонтировано одно в `AppLayout`.
|
||||
*/
|
||||
guildCreateOpen: boolean;
|
||||
openGuildCreate: () => void;
|
||||
closeGuildCreate: () => void;
|
||||
/** Открыто ли модальное окно настроек (открывается из панели пользователя). */
|
||||
settingsOpen: boolean;
|
||||
settingsSection: SettingsSection;
|
||||
@@ -75,6 +83,10 @@ export const useUiStore = create<UiState>((set, get) => ({
|
||||
get().setTheme(get().theme === 'dark' ? 'light' : 'dark');
|
||||
},
|
||||
|
||||
guildCreateOpen: false,
|
||||
openGuildCreate: () => set({ guildCreateOpen: true }),
|
||||
closeGuildCreate: () => set({ guildCreateOpen: false }),
|
||||
|
||||
settingsOpen: false,
|
||||
settingsSection: 'profile',
|
||||
settingsProfileTab: 'main',
|
||||
|
||||
Reference in New Issue
Block a user