From 1f878b61a19f1004030ec66640f287a40d2cc830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Mon, 21 Sep 2026 15:14:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=D0=BF=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B5=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B8=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=E2=80=94=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D1=83=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/TaskChat.tsx | 6 ++-- .../src/components/chat/useChatController.ts | 6 ++-- client/src/hooks/usePersistentDraft.ts | 32 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 client/src/hooks/usePersistentDraft.ts diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index bcb2937..25c8cae 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -14,6 +14,7 @@ import { import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; import { useEvents } from '@/hooks/useEvents'; +import { usePersistentDraft } from '@/hooks/usePersistentDraft'; import { Button } from '@/components/ui/button'; import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -178,8 +179,9 @@ const TaskChat = ({ taskId }: TaskChatProps) => { const textareaRef = useRef(null); const scrollAreaRef = useRef(null); const markReadTimerRef = useRef | null>(null); - // Composer: локальное состояние вместо react-hook-form (форма одного поля) - const [messageText, setMessageText] = useState(''); + // Composer: локальное состояние вместо react-hook-form (форма одного поля). + // Черновик персистентный: переживает перезагрузку страницы (SW-деплой, редирект авторизации) + const [messageText, setMessageText] = usePersistentDraft(`draft:task:${taskId}`); const [mentionedUserIds, setMentionedUserIds] = useState([]); const [mentionedBotIds, setMentionedBotIds] = useState([]); diff --git a/client/src/components/chat/useChatController.ts b/client/src/components/chat/useChatController.ts index 950cec2..bf1b511 100644 --- a/client/src/components/chat/useChatController.ts +++ b/client/src/components/chat/useChatController.ts @@ -13,6 +13,7 @@ import { getAll as getAllQueued } from '@/lib/offlineQueue'; import { reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa'; import { useAuth } from '@/hooks/useAuth'; import { useEvents } from '@/hooks/useEvents'; +import { usePersistentDraft } from '@/hooks/usePersistentDraft'; import { useToast } from '@/hooks/use-toast'; import { isPWAInstalled } from '@/lib/pwa'; import type { ChatAttachment } from '@/components/chat/ChatAttachments'; @@ -35,7 +36,8 @@ export function useChatController() { const [groupMembersOpen, setGroupMembersOpen] = useState(false); const [replyTo, setReplyTo] = useState(null); const [editingMsg, setEditingMsg] = useState(null); - const [messageText, setMessageText] = useState(''); + // Черновик персистентный: переживает перезагрузку страницы (SW-деплой, редирект авторизации) + const [messageText, setMessageText] = usePersistentDraft(`draft:conv:${activeConvId ?? 'none'}`); const [editText, setEditText] = useState(''); const [mentionedUserIds, setMentionedUserIds] = useState([]); const [mentionSearch, setMentionSearch] = useState(null); @@ -724,7 +726,7 @@ export function useChatController() { setActiveConvId(id); setReplyTo(null); setEditingMsg(null); - setMessageText(''); + // Черновик не очищаем: usePersistentDraft восстановит сохранённый текст этого диалога setSearchQuery(''); markReadMutation.mutate(id); if (isMobile) setMobileView('chat'); diff --git a/client/src/hooks/usePersistentDraft.ts b/client/src/hooks/usePersistentDraft.ts new file mode 100644 index 0000000..e3aede7 --- /dev/null +++ b/client/src/hooks/usePersistentDraft.ts @@ -0,0 +1,32 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; + +/** + * Черновик сообщения с персистентностью в localStorage. + * + * Текст неотправленного сообщения живёт только в памяти (useState) и пропадает + * при перезагрузке страницы (обновление Service Worker после деплоя, редирект + * авторизации, дискард вкладки). Хук сохраняет текст по ключу и восстанавливает + * при монтировании и при смене ключа (другой чат). Очистка — обычным setValue(''). + */ +export function usePersistentDraft(storageKey: string): [string, (value: string) => void] { + const [value, setValue] = useState(() => { + try { return localStorage.getItem(storageKey) ?? ''; } catch { return ''; } + }); + const keyRef = useRef(storageKey); + keyRef.current = storageKey; + + // Смена чата — восстанавливаем черновик этого чата + useEffect(() => { + try { setValue(localStorage.getItem(storageKey) ?? ''); } catch { setValue(''); } + }, [storageKey]); + + const set = useCallback((next: string) => { + setValue(next); + try { + if (next) localStorage.setItem(keyRef.current, next); + else localStorage.removeItem(keyRef.current); + } catch { /* localStorage недоступен (приватный режим и т.п.) */ } + }, []); + + return [value, set]; +}