Улучшить UX личного чата на мобильных

This commit is contained in:
AidarKC 2026-06-25 10:46:45 +04:00
parent e60475f351
commit 0f3c4a621d
5 changed files with 142 additions and 6 deletions

View File

@ -0,0 +1,24 @@
## Краткое описание
Доработан UX личного чата на мобильных устройствах:
- при открытой экранной клавиатуре нижний тулбар на 5 кнопок временно скрывается;
- после отправки собственного сообщения чат автоматически прокручивается вниз так, чтобы новое сообщение было видно сразу.
## Что проверять
- открыть личный чат на телефоне;
- тапнуть в поле ввода и убедиться, что нижний тулбар скрывается после появления клавиатуры;
- закрыть клавиатуру и убедиться, что тулбар возвращается;
- отправить короткое сообщение, находясь не в самом низу переписки;
- убедиться, что после отправки экран прокручивается вниз и новое сообщение видно сразу;
- проверить то же поведение после прихода подтверждения отправки/перерисовки списка.
## Ожидаемый результат
- клавиатура не конфликтует по высоте с нижним тулбаром;
- при наборе доступно больше вертикального места;
- собственное только что отправленное сообщение сразу попадает в видимую область.
## Статус
`pending`

View File

@ -1,2 +1,2 @@
client.version=1.2.263 client.version=1.2.264
server.version=1.2.248 server.version=1.2.248

View File

@ -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() { async function tryAutoLogin() {
if (!state.session.login || !state.session.sessionId) return; if (!state.session.login || !state.session.sessionId) return;
try { try {
@ -987,7 +1000,18 @@ async function init() {
} }
const pageId = getRoute().pageId || ''; 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(); renderApp();
} }
}); });

View File

@ -17,7 +17,7 @@ import {
} from '../state.js'; } from '../state.js';
import { startOutgoingCall } from '../services/call-service.js'; import { startOutgoingCall } from '../services/call-service.js';
import { openSpeechInputModal } from '../components/speech-input-modal.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'; import { showToast } from '../services/channels-ux.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; export const pageMeta = { id: 'chat-view', title: 'Чат' };
@ -60,6 +60,7 @@ function openMessageActionsMenu({
anchorX = 0, anchorX = 0,
anchorY = 0, anchorY = 0,
messageText = '', messageText = '',
showReadAloud = true,
canEdit = false, canEdit = false,
canDelete = false, canDelete = false,
onReadAloud, onReadAloud,
@ -74,7 +75,7 @@ function openMessageActionsMenu({
<div class="dm-floating-menu-layer" id="chat-message-actions-layer"> <div class="dm-floating-menu-layer" id="chat-message-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}"> <div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button> <button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button> ${showReadAloud ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>' : ''}
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''} ${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''} ${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
</div> </div>
@ -263,8 +264,13 @@ function resolveMessageEditedTimeMs(msg) {
function scrollToLatestMessage(list) { function scrollToLatestMessage(list) {
if (!list) return; if (!list) return;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const lastBubble = list.lastElementChild;
const apply = () => { const apply = () => {
list.scrollTop = list.scrollHeight; if (lastBubble?.scrollIntoView) {
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest' });
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}; };
apply(); apply();
window.requestAnimationFrame(apply); window.requestAnimationFrame(apply);
@ -334,6 +340,35 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
markChatRead(chatId); 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 }) { export function render({ navigate, route }) {
const routeChatId = route.params.chatId || 'u1'; const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1'; const chatId = normalizeDmChatId(routeChatId) || 'u1';
@ -345,6 +380,8 @@ export function render({ navigate, route }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-chat-screen'; 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 isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
const handleReadAloud = async (msg) => { const handleReadAloud = async (msg) => {
@ -431,7 +468,7 @@ export function render({ navigate, route }) {
</div> </div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea> <textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<div class="dm-actions-col"> <div class="dm-actions-col">
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button> ${isSpeechToTextReady ? '<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>' : ''}
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button> <button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button>
</div> </div>
`; `;
@ -441,6 +478,17 @@ export function render({ navigate, route }) {
const editBannerText = form.querySelector('#chat-edit-banner-text'); const editBannerText = form.querySelector('#chat-edit-banner-text');
const editCancelBtn = form.querySelector('#chat-edit-cancel'); const editCancelBtn = form.querySelector('#chat-edit-cancel');
let activeEdit = null; 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 = () => { const syncEditBanner = () => {
if (!editBanner || !editBannerText) return; if (!editBanner || !editBannerText) return;
@ -544,6 +592,7 @@ export function render({ navigate, route }) {
const editing = activeEdit; const editing = activeEdit;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text); const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderLog(log, chatId, { onOpenActions: handleOpenActions });
scrollToLatestMessage(log);
try { try {
let result; let result;
@ -621,6 +670,7 @@ export function render({ navigate, route }) {
anchorX: Number(event?.clientX || 0), anchorX: Number(event?.clientX || 0),
anchorY: Number(event?.clientY || 0), anchorY: Number(event?.clientY || 0),
messageText: msg?.text || '', messageText: msg?.text || '',
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2, canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2, canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
onReadAloud: async () => handleReadAloud(msg), onReadAloud: async () => handleReadAloud(msg),
@ -648,8 +698,14 @@ export function render({ navigate, route }) {
autoResizeComposer(input); autoResizeComposer(input);
input?.addEventListener('input', () => autoResizeComposer(input)); input?.addEventListener('input', () => autoResizeComposer(input));
input?.addEventListener('focus', () => { input?.addEventListener('focus', () => {
inputFocused = true;
syncKeyboardUi();
scrollToLatestMessage(log); scrollToLatestMessage(log);
}); });
input?.addEventListener('blur', () => {
inputFocused = false;
setChatKeyboardOpen(false);
});
input?.addEventListener('keydown', async (event) => { input?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return; if (event.key !== 'Enter') return;
if (event.ctrlKey) { if (event.ctrlKey) {
@ -699,12 +755,32 @@ export function render({ navigate, route }) {
await sendTextMessage(text); 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); wrap.append(log, form);
screen.append(wrap); screen.append(wrap);
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderLog(log, chatId, { onOpenActions: handleOpenActions });
window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 180); window.setTimeout(() => scrollToLatestMessage(log), 180);
void sendReadReceiptsForVisible(chatId); 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; return screen;
} }

View File

@ -65,6 +65,18 @@ body::before {
bottom: 0; bottom: 0;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); 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%); 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 { .connection-retry-banner {