Улучшить 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
|
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() {
|
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user