Улучшить UX личного чата на мобильных
This commit is contained in:
parent
e60475f351
commit
0f3c4a621d
@ -0,0 +1,24 @@
|
||||
## Краткое описание
|
||||
|
||||
Доработан UX личного чата на мобильных устройствах:
|
||||
- при открытой экранной клавиатуре нижний тулбар на 5 кнопок временно скрывается;
|
||||
- после отправки собственного сообщения чат автоматически прокручивается вниз так, чтобы новое сообщение было видно сразу.
|
||||
|
||||
## Что проверять
|
||||
|
||||
- открыть личный чат на телефоне;
|
||||
- тапнуть в поле ввода и убедиться, что нижний тулбар скрывается после появления клавиатуры;
|
||||
- закрыть клавиатуру и убедиться, что тулбар возвращается;
|
||||
- отправить короткое сообщение, находясь не в самом низу переписки;
|
||||
- убедиться, что после отправки экран прокручивается вниз и новое сообщение видно сразу;
|
||||
- проверить то же поведение после прихода подтверждения отправки/перерисовки списка.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- клавиатура не конфликтует по высоте с нижним тулбаром;
|
||||
- при наборе доступно больше вертикального места;
|
||||
- собственное только что отправленное сообщение сразу попадает в видимую область.
|
||||
|
||||
## Статус
|
||||
|
||||
`pending`
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.263
|
||||
client.version=1.2.264
|
||||
server.version=1.2.248
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -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({
|
||||
<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}">
|
||||
<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>' : ''}
|
||||
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
|
||||
</div>
|
||||
@ -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 }) {
|
||||
</div>
|
||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user