feat(instance): сервер установщика больше не «главный»
Понятие главного сервера убрано из поведения: сервер, создаваемый установщиком, обычный (is_main = false), автовступления в него нет, а в настройках инстанса он остаётся лишь справочно (main_guild_id → AdminGuildID в ответе API). Клиент от него больше не зависит: новичок начинает с пустым списком серверов.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
@@ -34,6 +34,12 @@ interface MessageItemProps {
|
||||
const actionButtonClass =
|
||||
'rounded-[var(--radius-sm)] px-1.5 py-0.5 text-sm leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95';
|
||||
|
||||
/**
|
||||
* Селектор интерактива: двойной клик по ссылке, реакции или кнопке не должен
|
||||
* ставить сообщение в ответ — этим элементам клик принадлежит целиком.
|
||||
*/
|
||||
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]';
|
||||
|
||||
/**
|
||||
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
|
||||
*
|
||||
@@ -140,10 +146,31 @@ export function MessageItem({
|
||||
.catch((error: unknown) => setActionError(error));
|
||||
};
|
||||
|
||||
/**
|
||||
* Двойной клик по сообщению — быстрый ответ (то же, что пункт «Ответить»).
|
||||
* Браузер сам ограничивает интервал двойного клика (~300–500 мс).
|
||||
*/
|
||||
const onDoubleClick = (event: ReactMouseEvent<HTMLElement>): void => {
|
||||
if (!canSend || pending) {
|
||||
return;
|
||||
}
|
||||
// Выделенный пользователем текст важнее: двойной клик — типичный способ
|
||||
// выделить слово, поэтому ответ в этом случае не ставим.
|
||||
if ((window.getSelection()?.toString() ?? '') !== '') {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest(INTERACTIVE_SELECTOR) !== null) {
|
||||
return;
|
||||
}
|
||||
onReply(message);
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
data-message-id={message.id}
|
||||
data-testid={`message-${message.id}`}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
|
||||
pending ? 'opacity-70' : ''
|
||||
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||
|
||||
interface PopoverProps {
|
||||
open: boolean;
|
||||
/** Доступное имя панели (озвучивается скринридером). */
|
||||
label: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** Элемент-якорь: клик по нему не считается кликом «вне панели». */
|
||||
anchorRef?: RefObject<HTMLElement | null>;
|
||||
/** Куда раскрывается панель: вверх (по умолчанию) или вниз. */
|
||||
placement?: 'top' | 'bottom';
|
||||
align?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Всплывающая панель произвольного содержимого (`role="dialog"`): в отличие от
|
||||
* `Menu` внутри неё могут быть слайдеры и ссылки, а не только пункты меню.
|
||||
*
|
||||
* Закрывается по Escape и клику вне панели — как меню; позиционирование задаёт
|
||||
* родитель (панель абсолютная, по умолчанию растёт вверх от нижней панели).
|
||||
*/
|
||||
export function Popover({
|
||||
open,
|
||||
label,
|
||||
onClose,
|
||||
children,
|
||||
anchorRef,
|
||||
placement = 'top',
|
||||
align = 'left',
|
||||
className = '',
|
||||
}: PopoverProps) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
// Клик вне панели (и вне якоря) закрывает её.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onPointerDown = (event: Event): void => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (panelRef.current?.contains(target) === true) {
|
||||
return;
|
||||
}
|
||||
if (anchorRef?.current?.contains(target) === true) {
|
||||
return;
|
||||
}
|
||||
onCloseRef.current();
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('touchstart', onPointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('touchstart', onPointerDown);
|
||||
};
|
||||
}, [open, anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRef.current();
|
||||
anchorRef?.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open, anchorRef]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
className={`absolute z-40 w-64 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 shadow-[var(--shadow-3)] ${
|
||||
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
|
||||
} ${align === 'right' ? 'right-0' : 'left-0'} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user