feat(guilds): приватные комнаты — права доступа поверх серверных
Сервер:
- store: оверрайды всех комнат сервера одним запросом (без N+1) и снятие
оверрайда с признаком «был ли он»;
- API: PUT/DELETE /guilds/{id}/channels/{cid}/overwrites/{role|user}/{tid}
(права именами через `|`, как у ролей), step-up на изменение, проверка что
роль принадлежит серверу, а участник состоит в нём;
- список комнат отдаёт permission_overwrites; после правки сбрасывается кэш
прав комнаты, пишется аудит (channel.overwrite_set/delete) и уходит событие
GUILD_CHANNELS_SYNC — видимость комнаты меняется у всех участников.
Клиент:
- редактор «Доступ к комнате»: приватность одним переключателем (запрет
VIEW_CHANNEL для @everyone), права просмотра/переписки/входа для ролей и
участников, подтверждение личности по требованию сервера;
- в настройках комнаты теперь и голосовые комнаты (фон и права), вебхуки —
только у текстовых; событие GUILD_CHANNELS_SYNC перечитывает список комнат.
Тесты: 2 Go-теста (скрытие и открытие комнаты ролями, проверка цели и прав) и
3 web-теста редактора (маски, step-up, скрытие без MANAGE_ROLES).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { parseGuildEmojiList } from './emojis';
|
||||
import { parseGuildSoundList } from './sounds';
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
import { userStatuses, type Channel, type ChannelOverwrite, type UserStatus } from './types';
|
||||
import type { GatewayUser } from './gateway';
|
||||
import type { GuildEmoji } from './emojis';
|
||||
import type { GuildSound } from './sounds';
|
||||
@@ -284,6 +284,42 @@ export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null
|
||||
}
|
||||
|
||||
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
|
||||
/** `GUILD_CHANNELS_SYNC`: права комнат сервера изменились — перечитать список. */
|
||||
export function parseGuildChannelsSyncEvent(value: unknown): { guild_id: string } | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const guildId = asString(record['guild_id']);
|
||||
return guildId === undefined ? null : { guild_id: guildId };
|
||||
}
|
||||
|
||||
/** parseChannelOverwrites читает список оверрайдов прав комнаты. */
|
||||
export function parseChannelOverwrites(value: unknown): ChannelOverwrite[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const result: ChannelOverwrite[] = [];
|
||||
for (const item of value) {
|
||||
const record = asRecord(item);
|
||||
if (record === null) {
|
||||
continue;
|
||||
}
|
||||
const targetType = asString(record['target_type']);
|
||||
const targetId = asString(record['target_id']);
|
||||
if ((targetType !== 'role' && targetType !== 'user') || targetId === undefined) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
allow: asString(record['allow']) ?? '0',
|
||||
deny: asString(record['deny']) ?? '0',
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseChannelPayload(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
@@ -327,6 +363,11 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
||||
if (typeof record['can_view'] === 'boolean') {
|
||||
channel.can_view = record['can_view'];
|
||||
}
|
||||
// Права комнаты: нужны редактору доступа (AGENT.md 6.2).
|
||||
const overwrites = parseChannelOverwrites(record['permission_overwrites']);
|
||||
if (overwrites.length > 0) {
|
||||
channel.permission_overwrites = overwrites;
|
||||
}
|
||||
// Фон комнаты: приходит в READY и CHANNEL_UPDATE (AGENT.md 7.5).
|
||||
const backgroundFileId = asString(record['background_file_id']);
|
||||
if (backgroundFileId !== undefined) {
|
||||
|
||||
@@ -120,6 +120,45 @@ export async function updateRole(
|
||||
return payload.role;
|
||||
}
|
||||
|
||||
/** Права комнаты в масках (AGENT.md 6.2): тем же способом, что права роли. */
|
||||
export interface ChannelOverwriteInput {
|
||||
allow: string;
|
||||
deny: string;
|
||||
step_up_password?: string;
|
||||
step_up_totp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `PUT /guilds/{id}/channels/{cid}/overwrites/{type}/{tid}` — права комнаты
|
||||
* для роли или участника поверх серверных.
|
||||
*/
|
||||
export async function setChannelOverwrite(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
targetType: 'role' | 'user',
|
||||
targetId: string,
|
||||
body: ChannelOverwriteInput,
|
||||
): Promise<Channel> {
|
||||
const payload = await request<{ channel: Channel }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/channels/${encodeURIComponent(channelId)}/overwrites/${targetType}/${encodeURIComponent(targetId)}`,
|
||||
{ method: 'PUT', body },
|
||||
);
|
||||
return payload.channel;
|
||||
}
|
||||
|
||||
/** `DELETE .../overwrites/{type}/{tid}` — снять права комнаты (нужен step-up). */
|
||||
export function deleteChannelOverwrite(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
targetType: 'role' | 'user',
|
||||
targetId: string,
|
||||
): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/channels/${encodeURIComponent(channelId)}/overwrites/${targetType}/${encodeURIComponent(targetId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
export function joinGuild(guildId: string): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' });
|
||||
}
|
||||
|
||||
@@ -75,6 +75,14 @@ export interface Role {
|
||||
nick_effect?: string;
|
||||
}
|
||||
|
||||
/** Оверрайд прав комнаты для роли или участника (AGENT.md 6.2). */
|
||||
export interface ChannelOverwrite {
|
||||
target_type: 'role' | 'user';
|
||||
target_id: string;
|
||||
allow: string;
|
||||
deny: string;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
/** Фон комнаты: файл-картинка за лентой сообщений (AGENT.md 7.5). */
|
||||
@@ -89,6 +97,8 @@ export interface Channel {
|
||||
can_view?: boolean;
|
||||
can_send?: boolean;
|
||||
can_connect?: boolean;
|
||||
/** Права комнаты поверх серверных (AGENT.md 6.2): маски приходят строками. */
|
||||
permission_overwrites?: ChannelOverwrite[];
|
||||
/**
|
||||
* Поля личной беседы (`type: 'dm'`): id собеседника, его аватар и видимый
|
||||
* статус. В снапшоте READY аватар приходит как `icon_file_id`, в REST-списке
|
||||
|
||||
@@ -1182,7 +1182,19 @@
|
||||
"upload": "Upload background",
|
||||
"remove": "Remove background",
|
||||
"notImage": "An image file is required",
|
||||
"tooLarge": "File is larger than 5 MB"
|
||||
"tooLarge": "File is larger than 5 MB",
|
||||
"permissions": {
|
||||
"title": "Channel access",
|
||||
"description": "Channel permissions on top of server ones: a private channel is hidden from @everyone, access is granted to roles and individual members.",
|
||||
"private": "Private channel (hide from @everyone)",
|
||||
"everyone": "@everyone",
|
||||
"view": "View",
|
||||
"send": "Send",
|
||||
"connect": "Connect",
|
||||
"inherit": "Server default",
|
||||
"allow": "Allow",
|
||||
"deny": "Deny"
|
||||
}
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
|
||||
@@ -1182,7 +1182,19 @@
|
||||
"upload": "Загрузить фон",
|
||||
"remove": "Убрать фон",
|
||||
"notImage": "Нужен файл изображения",
|
||||
"tooLarge": "Файл больше 5 МБ"
|
||||
"tooLarge": "Файл больше 5 МБ",
|
||||
"permissions": {
|
||||
"title": "Доступ к комнате",
|
||||
"description": "Права комнаты поверх серверных: приватная комната скрыта для @everyone, доступ выдают роли и отдельные участники.",
|
||||
"private": "Приватная комната (скрыть от @everyone)",
|
||||
"everyone": "@everyone",
|
||||
"view": "Просмотр",
|
||||
"send": "Писать",
|
||||
"connect": "Вход",
|
||||
"inherit": "Как на сервере",
|
||||
"allow": "Разрешено",
|
||||
"deny": "Запрещено"
|
||||
}
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
deleteChannelOverwrite,
|
||||
fetchMembers,
|
||||
fetchRoles,
|
||||
guildChannelsQueryKey,
|
||||
guildMembersQueryKey,
|
||||
guildRolesQueryKey,
|
||||
setChannelOverwrite,
|
||||
} from '@/api/guilds';
|
||||
import type { Channel, ChannelOverwrite, GuildMember, Role } from '@/api/types';
|
||||
import { stepUp } from '@/api/auth';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { errorCode } from '@/lib/format';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
|
||||
/** Имена прав, которыми управляет редактор доступа (AGENT.md 6.2). */
|
||||
const VIEW_CHANNEL = 'VIEW_CHANNEL';
|
||||
const SEND_MESSAGES = 'SEND_MESSAGES';
|
||||
const CONNECT_VOICE = 'CONNECT_VOICE';
|
||||
|
||||
/** Три состояния права: как на сервере, разрешено, запрещено. */
|
||||
type TriState = 'inherit' | 'allow' | 'deny';
|
||||
|
||||
/** Действие над оверрайдом: задать права или снять цель целиком. */
|
||||
type PendingAction =
|
||||
| { kind: 'set'; targetType: 'role' | 'user'; targetId: string; allow: string; deny: string }
|
||||
| { kind: 'delete'; targetType: 'role' | 'user'; targetId: string };
|
||||
|
||||
/** names разбирает строку прав «A|B» в список, отбрасывая пустые значения. */
|
||||
function names(value: string | undefined): string[] {
|
||||
return (value ?? '').split('|').filter((item) => item !== '');
|
||||
}
|
||||
|
||||
interface ChannelPermissionsSectionProps {
|
||||
guildId: string | null;
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
/** stateOf читает состояние одного права из оверрайда. */
|
||||
function stateOf(overwrite: ChannelOverwrite | undefined, name: string): TriState {
|
||||
if (overwrite === undefined) {
|
||||
return 'inherit';
|
||||
}
|
||||
if (names(overwrite.allow).includes(name)) {
|
||||
return 'allow';
|
||||
}
|
||||
if (names(overwrite.deny).includes(name)) {
|
||||
return 'deny';
|
||||
}
|
||||
return 'inherit';
|
||||
}
|
||||
|
||||
/** applyState меняет одно право, сохраняя остальные (AGENT.md 6.2). */
|
||||
function applyState(
|
||||
overwrite: ChannelOverwrite | undefined,
|
||||
name: string,
|
||||
state: TriState,
|
||||
): { allow: string; deny: string } {
|
||||
const allow = names(overwrite?.allow).filter((item) => item !== name);
|
||||
const deny = names(overwrite?.deny).filter((item) => item !== name);
|
||||
if (state === 'allow') {
|
||||
allow.push(name);
|
||||
}
|
||||
if (state === 'deny') {
|
||||
deny.push(name);
|
||||
}
|
||||
return { allow: allow.join('|'), deny: deny.join('|') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактор доступа к комнате (AGENT.md 6.2, 7.5): приватность для @everyone
|
||||
* плюс права ролей и участников по каждому из ключевых прав. Изменение прав
|
||||
* требует подтверждения личности (AGENT.md 9.3) — сервер отвечает
|
||||
* `auth.step_up_required`, после чего действие повторяется со step-up.
|
||||
*/
|
||||
export function ChannelPermissionsSection({ guildId, channel }: ChannelPermissionsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [stepUpPassword, setStepUpPassword] = useState('');
|
||||
const [stepUpCode, setStepUpCode] = useState('');
|
||||
|
||||
const roles = useQuery({
|
||||
queryKey: guildRolesQueryKey(guildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchRoles(guildId ?? '', signal),
|
||||
enabled: guildId !== null,
|
||||
retry: 0,
|
||||
});
|
||||
const members = useQuery({
|
||||
queryKey: guildMembersQueryKey(guildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal),
|
||||
enabled: guildId !== null,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const overwrites = useMemo(() => channel.permission_overwrites ?? [], [channel]);
|
||||
const defaultRole = useMemo(
|
||||
() => (roles.data ?? []).find((role) => role.is_default) ?? null,
|
||||
[roles.data],
|
||||
);
|
||||
const overwriteFor = (
|
||||
targetType: 'role' | 'user',
|
||||
targetId: string,
|
||||
): ChannelOverwrite | undefined =>
|
||||
overwrites.find((item) => item.target_type === targetType && item.target_id === targetId);
|
||||
|
||||
const isPrivate =
|
||||
stateOf(
|
||||
defaultRole === null ? undefined : overwriteFor('role', defaultRole.id),
|
||||
VIEW_CHANNEL,
|
||||
) === 'deny';
|
||||
|
||||
const invalidate = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
|
||||
};
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: async (input: PendingAction) => {
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
if (input.kind === 'set') {
|
||||
await setChannelOverwrite(guildId, channel.id, input.targetType, input.targetId, {
|
||||
allow: input.allow,
|
||||
deny: input.deny,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await deleteChannelOverwrite(guildId, channel.id, input.targetType, input.targetId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
setPendingAction(null);
|
||||
setStepUpPassword('');
|
||||
setStepUpCode('');
|
||||
invalidate();
|
||||
},
|
||||
onError: (cause: unknown, variables) => {
|
||||
setActionError(cause);
|
||||
// Сервер требует свежее подтверждение личности (AGENT.md 9.3): показываем
|
||||
// форму и повторяем то же действие после успешного step-up.
|
||||
if (errorCode(cause) === 'auth.step_up_required') {
|
||||
setPendingAction(variables);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const confirmIdentity = useMutation({
|
||||
mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode),
|
||||
onSuccess: () => {
|
||||
const retry = pendingAction;
|
||||
setStepUpPassword('');
|
||||
setStepUpCode('');
|
||||
if (retry !== null) {
|
||||
action.mutate(retry);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (guildId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissions: { name: string; label: string }[] =
|
||||
channel.type === 'voice'
|
||||
? [
|
||||
{ name: VIEW_CHANNEL, label: t('settings.channel.permissions.view') },
|
||||
{ name: CONNECT_VOICE, label: t('settings.channel.permissions.connect') },
|
||||
]
|
||||
: [
|
||||
{ name: VIEW_CHANNEL, label: t('settings.channel.permissions.view') },
|
||||
{ name: SEND_MESSAGES, label: t('settings.channel.permissions.send') },
|
||||
];
|
||||
|
||||
const rows: { key: string; title: string; targetType: 'role' | 'user'; targetId: string }[] = [
|
||||
...(roles.data ?? []).map((role: Role) => ({
|
||||
key: `role-${role.id}`,
|
||||
title: role.is_default ? t('settings.channel.permissions.everyone') : role.name,
|
||||
targetType: 'role' as const,
|
||||
targetId: role.id,
|
||||
})),
|
||||
...(members.data ?? []).map((member: GuildMember) => ({
|
||||
key: `user-${member.user_id}`,
|
||||
title: `${member.display_name} @${member.username ?? ''}`,
|
||||
targetType: 'user' as const,
|
||||
targetId: member.user_id,
|
||||
})),
|
||||
];
|
||||
|
||||
const togglePrivate = (next: boolean): void => {
|
||||
if (defaultRole === null) {
|
||||
return;
|
||||
}
|
||||
const existing = overwriteFor('role', defaultRole.id);
|
||||
if (!next) {
|
||||
// Снимаем оверрайд @everyone целиком: комната снова как на сервере.
|
||||
action.mutate({ kind: 'delete', targetType: 'role', targetId: defaultRole.id });
|
||||
return;
|
||||
}
|
||||
const masks = applyState(existing, VIEW_CHANNEL, 'deny');
|
||||
action.mutate({ kind: 'set', targetType: 'role', targetId: defaultRole.id, ...masks });
|
||||
};
|
||||
|
||||
const pending = action.isPending || confirmIdentity.isPending;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-base font-semibold">{t('settings.channel.permissions.title')}</h3>
|
||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.channel.permissions.description')}</p>
|
||||
|
||||
{actionError === null ? null : <ErrorNotice className="mt-3" error={actionError} />}
|
||||
|
||||
{pendingAction === null ? null : (
|
||||
<form
|
||||
className="mt-3 flex flex-wrap items-end gap-2 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-2"
|
||||
data-testid="channel-permissions-step-up"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
confirmIdentity.mutate();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={t('settings.security.stepUpPassword')}
|
||||
type="password"
|
||||
name="step_up_password"
|
||||
autoComplete="current-password"
|
||||
value={stepUpPassword}
|
||||
onChange={(event) => setStepUpPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
{currentUser.data?.totp_enabled === true ? (
|
||||
<Field
|
||||
label={t('auth.login.totp')}
|
||||
name="step_up_totp"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={8}
|
||||
value={stepUpCode}
|
||||
onChange={(event) => setStepUpCode(event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{confirmIdentity.isError ? <ErrorNotice error={confirmIdentity.error} /> : null}
|
||||
<Button type="submit" disabled={confirmIdentity.isPending}>
|
||||
{t('settings.security.stepUpSubmit')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setPendingAction(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<label className="mt-3 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="channel-private-toggle"
|
||||
checked={isPrivate}
|
||||
disabled={pending || defaultRole === null}
|
||||
onChange={(event) => togglePrivate(event.target.checked)}
|
||||
/>
|
||||
{t('settings.channel.permissions.private')}
|
||||
</label>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2" data-testid="channel-permissions">
|
||||
{rows.map((row) => {
|
||||
const overwrite = overwriteFor(row.targetType, row.targetId);
|
||||
const canEdit = !(row.targetType === 'user' && row.targetId === currentUser.data?.id);
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex flex-wrap items-center gap-3 border-b border-border/40 py-2 last:border-b-0"
|
||||
data-testid={`channel-permission-row-${row.targetId}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{row.title}</span>
|
||||
{permissions.map((permission) => (
|
||||
<label key={permission.label} className="flex items-center gap-1 text-xs">
|
||||
<span className="text-fg-muted">{permission.label}</span>
|
||||
<select
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-1 py-0.5"
|
||||
data-testid={`channel-permission-${row.targetId}-${permission.name.toLowerCase()}`}
|
||||
value={stateOf(overwrite, permission.name)}
|
||||
disabled={pending || !canEdit}
|
||||
onChange={(event) => {
|
||||
const masks = applyState(
|
||||
overwrite,
|
||||
permission.name,
|
||||
event.target.value as TriState,
|
||||
);
|
||||
action.mutate({
|
||||
kind: 'set',
|
||||
targetType: row.targetType,
|
||||
targetId: row.targetId,
|
||||
...masks,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="inherit">{t('settings.channel.permissions.inherit')}</option>
|
||||
<option value="allow">{t('settings.channel.permissions.allow')}</option>
|
||||
<option value="deny">{t('settings.channel.permissions.deny')}</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { canManageWebhooks } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { ChannelBackgroundSection } from '@/pages/settings/ChannelBackgroundSection';
|
||||
import { ChannelPermissionsSection } from '@/pages/settings/ChannelPermissionsSection';
|
||||
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
|
||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||
|
||||
@@ -80,21 +81,26 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const textChannels = useMemo(
|
||||
() => (channels.data ?? []).filter((channel: Channel) => channel.type === 'text'),
|
||||
// В списке и текстовые, и голосовые комнаты: у голосовых настраиваются фон и
|
||||
// права доступа (AGENT.md 7.5), вебхуки — только у текстовых.
|
||||
const selectableChannels = useMemo(
|
||||
() =>
|
||||
(channels.data ?? []).filter(
|
||||
(channel: Channel) => channel.type === 'text' || channel.type === 'voice',
|
||||
),
|
||||
[channels.data],
|
||||
);
|
||||
|
||||
// Первую текстовую комнату выбираем сами: список только что загрузился.
|
||||
// Первую комнату выбираем сами: список только что загрузился.
|
||||
useEffect(() => {
|
||||
if (settingsChannelId !== null || textChannels.length === 0) {
|
||||
if (settingsChannelId !== null || selectableChannels.length === 0) {
|
||||
return;
|
||||
}
|
||||
const first = textChannels[0];
|
||||
const first = selectableChannels[0];
|
||||
if (first !== undefined) {
|
||||
selectSettingsChannel(first.id);
|
||||
}
|
||||
}, [settingsChannelId, textChannels, selectSettingsChannel]);
|
||||
}, [settingsChannelId, selectableChannels, selectSettingsChannel]);
|
||||
|
||||
if (guildId === null || guild === null) {
|
||||
return (
|
||||
@@ -111,13 +117,19 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
|
||||
currentUser.data?.id,
|
||||
isInstanceAdmin,
|
||||
);
|
||||
// Права комнаты меняет тот, кто управляет ролями (AGENT.md 6.2).
|
||||
const canManagePermissions =
|
||||
isInstanceAdmin ||
|
||||
guild.owner_id === currentUser.data?.id ||
|
||||
(guild.my_permissions ?? []).includes('ADMINISTRATOR') ||
|
||||
(guild.my_permissions ?? []).includes('MANAGE_ROLES');
|
||||
// Фон комнаты — отдельное право MANAGE_CHANNEL_BACKGROUND (AGENT.md 7.5).
|
||||
const canManageBackground =
|
||||
isInstanceAdmin ||
|
||||
guild.owner_id === currentUser.data?.id ||
|
||||
(guild.my_permissions ?? []).includes('ADMINISTRATOR') ||
|
||||
(guild.my_permissions ?? []).includes('MANAGE_CHANNEL_BACKGROUND');
|
||||
const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null;
|
||||
const active = selectableChannels.find((channel) => channel.id === settingsChannelId) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -131,21 +143,21 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
|
||||
<ErrorNotice error={channels.error} onRetry={() => void channels.refetch()} />
|
||||
</Card>
|
||||
) : null}
|
||||
{channels.isSuccess && textChannels.length === 0 ? (
|
||||
{channels.isSuccess && selectableChannels.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t('settings.channel.noChannels')}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{textChannels.length === 0 ? null : (
|
||||
{selectableChannels.length === 0 ? null : (
|
||||
<Card>
|
||||
<SelectField
|
||||
label={t('settings.channel.pick')}
|
||||
value={active?.id ?? ''}
|
||||
onChange={(value) => selectSettingsChannel(value)}
|
||||
options={textChannels.map((channel) => ({
|
||||
options={selectableChannels.map((channel) => ({
|
||||
value: channel.id,
|
||||
label: `#${channel.name}`,
|
||||
label: `${channel.type === 'voice' ? '🔊' : '#'}${channel.name}`,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
@@ -159,11 +171,20 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
|
||||
backgroundFileId={active.background_file_id}
|
||||
canManage={canManageBackground}
|
||||
/>
|
||||
<ChannelWebhooksSection
|
||||
key={`${active.id}-webhooks`}
|
||||
channelId={active.id}
|
||||
canManage={canManage}
|
||||
/>
|
||||
{active.type === 'text' ? (
|
||||
<ChannelWebhooksSection
|
||||
key={`${active.id}-webhooks`}
|
||||
channelId={active.id}
|
||||
canManage={canManage}
|
||||
/>
|
||||
) : null}
|
||||
{canManagePermissions ? (
|
||||
<ChannelPermissionsSection
|
||||
key={`${active.id}-permissions`}
|
||||
guildId={guildId}
|
||||
channel={active}
|
||||
/>
|
||||
) : null}
|
||||
{canManage ? null : (
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t('settings.channel.noPermission')}</p>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/api/gateway';
|
||||
import {
|
||||
parseChannelDelete,
|
||||
parseGuildChannelsSyncEvent,
|
||||
parseChannelPayload,
|
||||
parseDmChannelEvent,
|
||||
parseGuildEmojisUpdate,
|
||||
@@ -334,6 +335,15 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'GUILD_CHANNELS_SYNC': {
|
||||
// Права комнаты изменились: видимость может поменяться у всех
|
||||
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
|
||||
const sync = parseGuildChannelsSyncEvent(event.d);
|
||||
if (sync !== null) {
|
||||
void session.reloadGuildChannels(sync.guild_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'DM_CHANNEL_CREATE': {
|
||||
// В событии только channel_id: список бесед забираем по REST.
|
||||
const direct = parseDmChannelEvent(event.d);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { GuildEmoji } from '@/api/emojis';
|
||||
import { fetchChannels } from '@/api/guilds';
|
||||
import { fetchDirectChannels } from '@/api/friends';
|
||||
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
|
||||
import type { GuildSound, SoundKind } from '@/api/sounds';
|
||||
@@ -113,6 +114,8 @@ interface SessionState {
|
||||
reloadDmChannels: () => Promise<void>;
|
||||
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
|
||||
removeChannel: (guildId: string, channelId: string) => void;
|
||||
/** GUILD_CHANNELS_SYNC: перечитать видимые комнаты сервера (права изменились). */
|
||||
reloadGuildChannels: (guildId: string) => Promise<void>;
|
||||
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */
|
||||
firstChannelId: (guildId: string) => string | null;
|
||||
selectGuild: (guildId: string | null) => void;
|
||||
@@ -351,6 +354,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
reloadGuildChannels: async (guildId) => {
|
||||
try {
|
||||
const channels = await fetchChannels(guildId);
|
||||
get().setGuildChannels(guildId, channels);
|
||||
} catch {
|
||||
// Сеть подвела: список обновится при следующем изменении прав.
|
||||
}
|
||||
},
|
||||
|
||||
setGuildChannels: (guildId, channels) => {
|
||||
set({
|
||||
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, channels } : guild)),
|
||||
|
||||
Reference in New Issue
Block a user