Исправить мобильный ввод в личном чате

This commit is contained in:
AidarKC 2026-06-25 11:20:30 +04:00
parent 827d2e9c3e
commit 8768e142e3
3 changed files with 56 additions and 27 deletions

View File

@ -6,6 +6,9 @@
- при открытии уже существующего личного чата стартовая позиция выбирается по хвосту переписки: - при открытии уже существующего личного чата стартовая позиция выбирается по хвосту переписки:
- если есть непрочитанные сообщения, открытие происходит на линии `Новые сообщения`; - если есть непрочитанные сообщения, открытие происходит на линии `Новые сообщения`;
- если непрочитанных нет, чат открывается сразу в самом низу. - если непрочитанных нет, чат открывается сразу в самом низу.
- на мобильной экранной клавиатуре `Enter` больше не отправляет сообщение, а создаёт новую строку;
- отправка выполняется только кнопкой `Отправить`;
- после отправки фокус остаётся в поле ввода, чтобы экранная клавиатура не закрывалась автоматически.
## Что проверять ## Что проверять
@ -17,6 +20,11 @@
- проверить то же поведение после прихода подтверждения отправки/перерисовки списка. - проверить то же поведение после прихода подтверждения отправки/перерисовки списка.
- открыть существующий чат с непрочитанными сообщениями и убедиться, что видна линия `Новые сообщения` и сообщения ниже неё; - открыть существующий чат с непрочитанными сообщениями и убедиться, что видна линия `Новые сообщения` и сообщения ниже неё;
- открыть существующий чат без непрочитанных сообщений и убедиться, что открыт конец переписки, а не начало. - открыть существующий чат без непрочитанных сообщений и убедиться, что открыт конец переписки, а не начало.
- на телефоне нажать кнопку `Enter` на экранной клавиатуре и убедиться, что появляется новая строка, а сообщение не уходит;
- нажать кнопку отправки и убедиться, что сообщение отправилось, осталось видимым внизу и клавиатура не закрылась;
- отдельно проверить два сценария прокрутки после отправки:
- пользователь уже почти внизу и прокрутка идёт плавно;
- пользователь был заметно выше и чат догоняет новый хвост без лишних скачков.
## Ожидаемый результат ## Ожидаемый результат
@ -24,6 +32,7 @@
- при наборе доступно больше вертикального места; - при наборе доступно больше вертикального места;
- собственное только что отправленное сообщение сразу попадает в видимую область. - собственное только что отправленное сообщение сразу попадает в видимую область.
- при открытии чата пользователь сразу попадает в актуальную часть переписки. - при открытии чата пользователь сразу попадает в актуальную часть переписки.
- мобильный ввод не конфликтует с привычным поведением экранной клавиатуры.
## Статус ## Статус

View File

@ -1,2 +1,2 @@
client.version=1.2.265 client.version=1.2.266
server.version=1.2.248 server.version=1.2.248

View File

@ -281,6 +281,30 @@ function scrollToLatestMessage(list) {
window.setTimeout(apply, 260); window.setTimeout(apply, 260);
} }
function scrollToLatestMessageSmart(list, { smoothIfNearBottom = false } = {}) {
if (!list) return;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const lastBubble = list.lastElementChild;
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
const distanceToBottom = Math.max(0, maxScrollTop - scrollContainer.scrollTop);
const useSmooth = smoothIfNearBottom && distanceToBottom <= 180;
const apply = (behavior = useSmooth ? 'smooth' : 'auto') => {
if (lastBubble?.scrollIntoView) {
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest', behavior });
}
if (behavior === 'smooth' && typeof scrollContainer.scrollTo === 'function') {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
} else {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
apply(useSmooth ? 'smooth' : 'auto');
window.requestAnimationFrame(() => apply('auto'));
window.setTimeout(() => apply('auto'), useSmooth ? 220 : 90);
}
function scrollToUnreadSeparator(list) { function scrollToUnreadSeparator(list) {
if (!list) return false; if (!list) return false;
const separator = list.querySelector('.chat-unread-separator'); const separator = list.querySelector('.chat-unread-separator');
@ -619,7 +643,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); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try { try {
let result; let result;
@ -657,10 +681,16 @@ export function render({ navigate, route }) {
} }
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderLog(log, chatId, { onOpenActions: handleOpenActions });
scrollToLatestMessage(log); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }));
window.setTimeout(() => scrollToLatestMessage(log), 90); window.setTimeout(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }), 220);
window.setTimeout(() => scrollToLatestMessage(log), 220); if (input) {
try {
input.focus({ preventScroll: true });
} catch {
input.focus();
}
}
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'outgoing-dm', source: 'outgoing-dm',
@ -735,27 +765,7 @@ export function render({ navigate, route }) {
}); });
input?.addEventListener('keydown', async (event) => { input?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return; if (event.key !== 'Enter') return;
if (event.ctrlKey) { window.requestAnimationFrame(() => autoResizeComposer(input));
event.preventDefault();
const start = Number(input.selectionStart ?? input.value.length);
const end = Number(input.selectionEnd ?? input.value.length);
const value = String(input.value || '');
input.value = `${value.slice(0, start)}\n${value.slice(end)}`;
const nextPos = start + 1;
try {
input.setSelectionRange(nextPos, nextPos);
} catch {
// ignore
}
autoResizeComposer(input);
return;
}
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
input.value = '';
autoResizeComposer(input);
await sendTextMessage(text);
}); });
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => { form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
@ -773,6 +783,15 @@ export function render({ navigate, route }) {
}); });
}); });
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
try {
input?.focus({ preventScroll: true });
} catch {
input?.focus();
}
});
form.addEventListener('submit', async (event) => { form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const text = String(input.value || '').trim(); const text = String(input.value || '').trim();
@ -780,6 +799,7 @@ export function render({ navigate, route }) {
input.value = ''; input.value = '';
autoResizeComposer(input); autoResizeComposer(input);
await sendTextMessage(text); await sendTextMessage(text);
focusInputToEnd();
}); });
const handleIncomingChatRefresh = async (event) => { const handleIncomingChatRefresh = async (event) => {