perf(vpn-bot): фоновая подготовка подписки, оптимизация запросов, анимация кнопок

- validateTelemostRoom: таймаут 3 сек + кэш 10 мин
- createVpnTask: транзакция + batch insert field values
- prepareVpnSubscription: валидация и создание подписки в фоне при получении ссылки
- sendVpnBotMessage: кэш botId, Promise.all для enrich/members/orgMap
- processPlatformSelection: одно сообщение с файлом и подпиской вместо двух
- Chat.tsx: нажатая кнопка становится зелёной галочкой, остальные disabled
This commit is contained in:
2026-07-17 12:38:33 +03:00
parent 6bb89b4cf8
commit 9f1c2e5b45
4 changed files with 224 additions and 103 deletions

View File

@@ -146,7 +146,7 @@ function groupButtonsByRow(buttons: BotButton[]): BotButton[][] {
function MessageBubble({
msg, isOwn, onReply, onEdit, onDelete, reactions, poll, onReactionsChange, onPollChange,
onOpenUserChat, onOpenBotChat, onButtonClick,
onOpenUserChat, onOpenBotChat, onButtonClick, pressedButtons,
}: {
msg: ConvMessage;
isOwn: boolean;
@@ -160,6 +160,7 @@ function MessageBubble({
onOpenUserChat?: (userId: number) => void;
onOpenBotChat?: (botId: number) => void;
onButtonClick?: (m: ConvMessage, button: BotButton) => void;
pressedButtons?: Set<string>;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const isEdited = msg.updatedAt && msg.updatedAt !== msg.createdAt;
@@ -313,17 +314,26 @@ function MessageBubble({
<div className={cn("flex flex-col gap-1.5 mt-1.5 max-w-full", isOwn ? "items-end" : "items-start")}>
{groupButtonsByRow(msg.botButtons).map((row, rowIdx) => (
<div key={rowIdx} className="flex flex-wrap gap-1.5">
{row.map((btn, btnIdx) => (
<Button
key={btnIdx}
variant={btn.style === 'primary' ? 'default' : btn.style === 'destructive' ? 'destructive' : btn.style === 'outline' ? 'outline' : 'secondary'}
size="sm"
className="h-7 text-xs px-3 py-1 rounded-full"
onClick={() => onButtonClick(msg, btn)}
>
{btn.text}
</Button>
))}
{row.map((btn, btnIdx) => {
const btnKey = `${msg.id}:${btn.callbackData}`;
const isPressed = pressedButtons?.has(btnKey) ?? false;
return (
<Button
key={btnIdx}
variant={isPressed ? 'default' : btn.style === 'primary' ? 'default' : btn.style === 'destructive' ? 'destructive' : btn.style === 'outline' ? 'outline' : 'secondary'}
size="sm"
className={cn(
"h-7 text-xs px-3 py-1 rounded-full",
isPressed && "bg-green-600 hover:bg-green-600 text-white"
)}
disabled={isPressed}
onClick={() => onButtonClick(msg, btn)}
>
{isPressed && <Check className="w-3 h-3 mr-1" />}
{btn.text}
</Button>
);
})}
</div>
))}
</div>
@@ -1000,6 +1010,8 @@ export default function ChatPage() {
// Typing indicator for bots
const [typingBots, setTypingBots] = useState<Set<string>>(new Set());
const typingTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Pressed bot buttons (messageId:callbackData)
const [pressedButtons, setPressedButtons] = useState<Set<string>>(new Set());
// File attachments
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -1406,10 +1418,11 @@ export default function ChatPage() {
};
useEffect(() => {
// Clear all typing indicators when switching conversations
// Clear all typing indicators and pressed buttons when switching conversations
typingTimersRef.current.forEach(timer => clearTimeout(timer));
typingTimersRef.current.clear();
setTypingBots(new Set());
setPressedButtons(new Set());
}, [activeConvId]);
// ── SSE ───────────────────────────────────────────────────────────────────────
@@ -2402,8 +2415,16 @@ export default function ChatPage() {
openBotDirectMutation.mutate(botId);
}}
onButtonClick={(m, btn) => {
const btnKey = `${m.id}:${btn.callbackData}`;
setPressedButtons(prev => {
if (prev.has(btnKey)) return prev;
const next = new Set(prev);
next.add(btnKey);
return next;
});
botButtonCallbackMutation.mutate({ messageId: m.id, callbackData: btn.callbackData });
}}
pressedButtons={pressedButtons}
/>
))}
</div>