From 0f3c4a621d5ee0c75d3d3e9f972b973b06beafe69040291ed0d4705b2f689ed1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 25 Jun 2026 10:46:45 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20UX=20=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B0=20=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...645_chat_mobile_keyboard_and_autoscroll.md | 24 ++++++ VERSION.properties | 2 +- shine-UI/js/app.js | 26 +++++- shine-UI/js/pages/chat-view.js | 84 ++++++++++++++++++- shine-UI/styles/layout.css | 12 +++ 5 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-25_0645_chat_mobile_keyboard_and_autoscroll.md diff --git a/Dev_Docs/Pending_Features/2026-06-25_0645_chat_mobile_keyboard_and_autoscroll.md b/Dev_Docs/Pending_Features/2026-06-25_0645_chat_mobile_keyboard_and_autoscroll.md new file mode 100644 index 0000000..ad4c523 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-25_0645_chat_mobile_keyboard_and_autoscroll.md @@ -0,0 +1,24 @@ +## Краткое описание + +Доработан UX личного чата на мобильных устройствах: +- при открытой экранной клавиатуре нижний тулбар на 5 кнопок временно скрывается; +- после отправки собственного сообщения чат автоматически прокручивается вниз так, чтобы новое сообщение было видно сразу. + +## Что проверять + +- открыть личный чат на телефоне; +- тапнуть в поле ввода и убедиться, что нижний тулбар скрывается после появления клавиатуры; +- закрыть клавиатуру и убедиться, что тулбар возвращается; +- отправить короткое сообщение, находясь не в самом низу переписки; +- убедиться, что после отправки экран прокручивается вниз и новое сообщение видно сразу; +- проверить то же поведение после прихода подтверждения отправки/перерисовки списка. + +## Ожидаемый результат + +- клавиатура не конфликтует по высоте с нижним тулбаром; +- при наборе доступно больше вертикального места; +- собственное только что отправленное сообщение сразу попадает в видимую область. + +## Статус + +`pending` diff --git a/VERSION.properties b/VERSION.properties index 62408f5..d5a53d2 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.263 +client.version=1.2.264 server.version=1.2.248 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index e3a6eaf..d641ae6 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -745,6 +745,19 @@ function renderApp() { } } +function refreshToolbarOnly() { + const route = getRoute(); + const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); + const page = routes[pageId] || routes['start-view']; + const showAppChrome = page.pageMeta?.showAppChrome !== false; + + toolbarEl.innerHTML = ''; + if (showAppChrome) { + toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); + } + refreshConnectionUi(); +} + async function tryAutoLogin() { if (!state.session.login || !state.session.sessionId) return; try { @@ -987,7 +1000,18 @@ async function init() { } const pageId = getRoute().pageId || ''; - if (pageId === 'chat-view' || pageId === 'messages-list' || shouldRefreshToolbarUnread) { + if (pageId === 'chat-view') { + window.dispatchEvent(new CustomEvent('shine-chat-messages-updated', { + detail: { + chatId, + messageType, + messageKey, + }, + })); + if (shouldRefreshToolbarUnread) { + refreshToolbarOnly(); + } + } else if (pageId === 'messages-list' || shouldRefreshToolbarUnread) { renderApp(); } }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index f9e9af7..c698b37 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -17,7 +17,7 @@ import { } from '../state.js'; import { startOutgoingCall } from '../services/call-service.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js'; -import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; +import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { showToast } from '../services/channels-ux.js'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; @@ -60,6 +60,7 @@ function openMessageActionsMenu({ anchorX = 0, anchorY = 0, messageText = '', + showReadAloud = true, canEdit = false, canDelete = false, onReadAloud, @@ -74,7 +75,7 @@ function openMessageActionsMenu({
@@ -263,8 +264,13 @@ function resolveMessageEditedTimeMs(msg) { function scrollToLatestMessage(list) { if (!list) return; + const scrollContainer = list.closest('.screen-content') || list.parentElement || list; + const lastBubble = list.lastElementChild; const apply = () => { - list.scrollTop = list.scrollHeight; + if (lastBubble?.scrollIntoView) { + lastBubble.scrollIntoView({ block: 'end', inline: 'nearest' }); + } + scrollContainer.scrollTop = scrollContainer.scrollHeight; }; apply(); window.requestAnimationFrame(apply); @@ -334,6 +340,35 @@ function renderLog(list, chatId, { onOpenActions } = {}) { markChatRead(chatId); } +function preserveComposerSelection(input, callback) { + if (!input || typeof callback !== 'function') { + if (typeof callback === 'function') callback(); + return; + } + + const wasFocused = document.activeElement === input; + const start = Number(input.selectionStart ?? input.value.length); + const end = Number(input.selectionEnd ?? input.value.length); + + callback(); + + if (!wasFocused) return; + try { + input.focus({ preventScroll: true }); + } catch { + input.focus(); + } + try { + input.setSelectionRange(start, end); + } catch { + // ignore + } +} + +function setChatKeyboardOpen(isOpen) { + document.body.classList.toggle('chat-keyboard-open', !!isOpen); +} + export function render({ navigate, route }) { const routeChatId = route.params.chatId || 'u1'; const chatId = normalizeDmChatId(routeChatId) || 'u1'; @@ -345,6 +380,8 @@ export function render({ navigate, route }) { const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-chat-screen'; + const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings); + const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings); const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); const handleReadAloud = async (msg) => { @@ -431,7 +468,7 @@ export function render({ navigate, route }) {
- + ${isSpeechToTextReady ? '' : ''}
`; @@ -441,6 +478,17 @@ export function render({ navigate, route }) { const editBannerText = form.querySelector('#chat-edit-banner-text'); const editCancelBtn = form.querySelector('#chat-edit-cancel'); let activeEdit = null; + let inputFocused = false; + const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0); + + const syncKeyboardUi = () => { + const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0); + const viewportShrunk = baseViewportHeight - viewportHeight > 120; + setChatKeyboardOpen(inputFocused && viewportShrunk); + if (inputFocused) { + window.requestAnimationFrame(() => scrollToLatestMessage(log)); + } + }; const syncEditBanner = () => { if (!editBanner || !editBannerText) return; @@ -544,6 +592,7 @@ export function render({ navigate, route }) { const editing = activeEdit; const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text); renderLog(log, chatId, { onOpenActions: handleOpenActions }); + scrollToLatestMessage(log); try { let result; @@ -621,6 +670,7 @@ export function render({ navigate, route }) { anchorX: Number(event?.clientX || 0), anchorY: Number(event?.clientY || 0), messageText: msg?.text || '', + showReadAloud: isTextToSpeechReady, canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2, canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2, onReadAloud: async () => handleReadAloud(msg), @@ -648,8 +698,14 @@ export function render({ navigate, route }) { autoResizeComposer(input); input?.addEventListener('input', () => autoResizeComposer(input)); input?.addEventListener('focus', () => { + inputFocused = true; + syncKeyboardUi(); scrollToLatestMessage(log); }); + input?.addEventListener('blur', () => { + inputFocused = false; + setChatKeyboardOpen(false); + }); input?.addEventListener('keydown', async (event) => { if (event.key !== 'Enter') return; if (event.ctrlKey) { @@ -699,12 +755,32 @@ export function render({ navigate, route }) { await sendTextMessage(text); }); + const handleIncomingChatRefresh = async (event) => { + const updatedChatId = normalizeDmChatId(event?.detail?.chatId); + if (updatedChatId !== chatId) return; + preserveComposerSelection(input, () => { + renderLog(log, chatId, { onOpenActions: handleOpenActions }); + }); + window.requestAnimationFrame(() => scrollToLatestMessage(log)); + void sendReadReceiptsForVisible(chatId); + }; + + window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); + window.visualViewport?.addEventListener('resize', syncKeyboardUi); + window.addEventListener('resize', syncKeyboardUi); + wrap.append(log, form); screen.append(wrap); renderLog(log, chatId, { onOpenActions: handleOpenActions }); window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.setTimeout(() => scrollToLatestMessage(log), 180); void sendReadReceiptsForVisible(chatId); + screen.cleanup = () => { + setChatKeyboardOpen(false); + window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); + window.visualViewport?.removeEventListener('resize', syncKeyboardUi); + window.removeEventListener('resize', syncKeyboardUi); + }; return screen; } diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css index 91d90b3..4b44cc0 100644 --- a/shine-UI/styles/layout.css +++ b/shine-UI/styles/layout.css @@ -65,6 +65,18 @@ body::before { bottom: 0; padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%); + transition: opacity 0.18s ease, transform 0.18s ease; +} + +body.chat-keyboard-open .screen-content { + bottom: 0; + padding-bottom: calc(14px + env(safe-area-inset-bottom)); +} + +body.chat-keyboard-open .toolbar-slot { + opacity: 0; + pointer-events: none; + transform: translateY(calc(100% + env(safe-area-inset-bottom))); } .connection-retry-banner {