Улучшить раскладку чата при клавиатуре

This commit is contained in:
2026-08-20 07:12:46 +03:00
parent 4656a03ea9
commit 8741be6cba
6 changed files with 529 additions and 104 deletions
+106 -1
View File
@@ -82,7 +82,7 @@ import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js?v=202607152145';
import * as chatView from './pages/chat-view.js?v=202608191738';
import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js';
@@ -221,6 +221,62 @@ function setKeyboardOffsetPx(valuePx = 0) {
setShellMetricVar('--keyboard-offset', valuePx);
}
let stableViewportHeightPx = Math.max(
1,
Math.round(window.innerHeight || document.documentElement?.clientHeight || window.visualViewport?.height || 0),
);
function isTextEntryFocused() {
const active = document.activeElement;
return active instanceof HTMLTextAreaElement
|| (active instanceof HTMLInputElement && !['button', 'checkbox', 'radio', 'range', 'file', 'submit', 'reset'].includes(active.type));
}
function syncViewportMetrics() {
if (!appShellEl) return;
const viewport = window.visualViewport || null;
const currentLayoutHeightPx = Math.max(
1,
Math.round(window.innerHeight || document.documentElement?.clientHeight || viewport?.height || 0),
);
const widthPx = Math.max(1, Math.round(window.innerWidth || viewport?.width || 0));
const offsetLeftPx = Math.max(0, Math.round(viewport?.offsetLeft || 0));
const textEntryFocused = isTextEntryFocused();
// Android Chrome/Firefox могут уменьшать и visualViewport, и innerHeight.
// Поэтому высоту экрана до открытия клавиатуры запоминаем отдельно и во
// время ввода НЕ переписываем ею app-shell. Иначе toolbar тоже поднимется.
if (!textEntryFocused) {
stableViewportHeightPx = Math.max(stableViewportHeightPx, currentLayoutHeightPx);
// После поворота/реального resize разрешаем уменьшить базу, но только
// когда никакое текстовое поле не держит экранную клавиатуру.
if (Math.abs(stableViewportHeightPx - currentLayoutHeightPx) > 220) {
stableViewportHeightPx = currentLayoutHeightPx;
}
} else if (currentLayoutHeightPx > stableViewportHeightPx) {
stableViewportHeightPx = currentLayoutHeightPx;
}
const visualBottomPx = viewport
? Math.round((viewport.offsetTop || 0) + viewport.height)
: currentLayoutHeightPx;
const rawKeyboardOffsetPx = Math.max(
0,
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
);
// Address bar Android обычно даёт небольшую дельту; клавиатура — заметно больше.
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
? rawKeyboardOffsetPx
: 0;
setShellMetricVar('--app-viewport-width', widthPx);
setShellMetricVar('--app-viewport-height', keyboardOffsetPx > 0 ? stableViewportHeightPx : currentLayoutHeightPx);
setShellMetricVar('--app-viewport-offset-top', 0);
setShellMetricVar('--app-viewport-offset-left', offsetLeftPx);
setKeyboardOffsetPx(keyboardOffsetPx);
appShellEl.classList.toggle('keyboard-open', keyboardOffsetPx > 0);
}
function attachSlotHeightObserver(slotEl, cssVarName) {
if (!slotEl || typeof ResizeObserver !== 'function') return null;
const sync = () => {
@@ -237,6 +293,55 @@ const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height');
syncViewportMetrics();
window.visualViewport?.addEventListener('resize', syncViewportMetrics);
window.visualViewport?.addEventListener('scroll', syncViewportMetrics);
window.addEventListener('resize', syncViewportMetrics);
document.addEventListener('focusin', () => {
requestAnimationFrame(syncViewportMetrics);
// Samsung One UI / Firefox can finish the OSK viewport transition several
// frames after focus. Re-sample through the animation instead of trusting
// the first resize event.
window.setTimeout(syncViewportMetrics, 80);
window.setTimeout(syncViewportMetrics, 180);
window.setTimeout(syncViewportMetrics, 320);
});
document.addEventListener('focusout', () => {
window.setTimeout(syncViewportMetrics, 80);
window.setTimeout(syncViewportMetrics, 220);
});
// Optional on-device viewport diagnostics: append ?keyboard-debug=1 to the URL.
if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
const debugEl = document.createElement('pre');
debugEl.id = 'keyboard-viewport-debug';
Object.assign(debugEl.style, {
position: 'fixed', top: '4px', right: '4px', zIndex: '999999', margin: '0',
maxWidth: '94vw', padding: '6px 8px', fontSize: '10px', lineHeight: '1.25',
color: '#fff', background: 'rgba(0,0,0,.82)', pointerEvents: 'none',
whiteSpace: 'pre-wrap',
});
document.body.append(debugEl);
const syncDebug = () => {
const vv = window.visualViewport;
debugEl.textContent = [
`innerHeight=${window.innerHeight}`,
`clientHeight=${document.documentElement.clientHeight}`,
`vv.height=${vv ? Math.round(vv.height) : 'n/a'}`,
`vv.offsetTop=${vv ? Math.round(vv.offsetTop) : 'n/a'}`,
`stable=${stableViewportHeightPx}`,
`keyboard=${getComputedStyle(appShellEl).getPropertyValue('--keyboard-offset').trim()}`,
`focused=${isTextEntryFocused()}`,
].join(' | ');
};
window.visualViewport?.addEventListener('resize', syncDebug);
window.visualViewport?.addEventListener('scroll', syncDebug);
window.addEventListener('resize', syncDebug);
document.addEventListener('focusin', () => window.setTimeout(syncDebug, 330));
document.addEventListener('focusout', () => window.setTimeout(syncDebug, 230));
syncDebug();
}
function clearSlot(slotEl, cssVarName) {
if (!slotEl) return;
slotEl.innerHTML = '';
+89 -54
View File
@@ -572,12 +572,27 @@ function scrollToUnreadSeparator(list) {
return true;
}
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) {
function renderLog(
list,
chatId,
{
onOpenActions,
markAsRead = true,
scrollMode = 'latest',
showUnreadSeparator = true,
unreadSeparatorMessageKey = '',
} = {},
) {
list.innerHTML = '';
const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false;
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
messages.forEach((msg) => {
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
const isUnreadBoundary = showUnreadSeparator
&& !unreadSeparatorInserted
&& separatorMessageKey
&& String(msg?.messageKey || '').trim() === separatorMessageKey;
if (isUnreadBoundary) {
const sep = document.createElement('div');
sep.className = 'chat-unread-separator';
const label = document.createElement('span');
@@ -781,12 +796,8 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
}
}
function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
document.body.classList.toggle('chat-toolbar-persistent', !!isOpen);
}
export function render({ navigate, route, chrome }) {
document.body.classList.add('chat-topbar-overlay');
const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
@@ -800,12 +811,53 @@ export function render({ navigate, route, chrome }) {
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
let historyHasMore = true;
let historyLoading = false;
let historyNextBeforeTimeMs = 0;
let historyNextBeforeMessageKey = '';
let historyBootstrapped = false;
let boundScrollContainer = null;
let unreadSeparatorVisible = hasUnreadIncoming;
let unreadSeparatorHideTimer = null;
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
const clearUnreadSeparatorHideTimer = () => {
if (unreadSeparatorHideTimer) {
window.clearTimeout(unreadSeparatorHideTimer);
unreadSeparatorHideTimer = null;
}
};
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead,
scrollMode,
showUnreadSeparator: unreadSeparatorVisible,
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
});
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
};
const hideUnreadSeparator = ({ rerender = true } = {}) => {
clearUnreadSeparatorHideTimer();
if (!unreadSeparatorVisible) return;
unreadSeparatorVisible = false;
if (rerender) {
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
const scheduleUnreadSeparatorAutoHide = () => {
clearUnreadSeparatorHideTimer();
if (!unreadSeparatorVisible) return;
unreadSeparatorHideTimer = window.setTimeout(() => {
hideUnreadSeparator({ rerender: true });
}, UNREAD_SEPARATOR_AUTO_HIDE_MS);
};
const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
@@ -819,13 +871,13 @@ export function render({ navigate, route, chrome }) {
const handleStartCall = async (mode = 'audio') => {
try {
await startOutgoingCall(chatId, { mode });
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
@@ -848,11 +900,7 @@ export function render({ navigate, route, chrome }) {
unread: false,
rawBlobB64: String(result?.localBlobB64 || ''),
});
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: 'latest',
});
renderChatLog({ scrollMode: 'latest', markAsRead: false });
notifyUnreadStateUpdated();
};
@@ -1010,29 +1058,6 @@ export function render({ navigate, route, chrome }) {
let inputFocused = false;
let emojiPickerOpen = false;
let emojiSelection = null;
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const appShell = document.querySelector('.app-shell');
const setKeyboardInset = (valuePx = 0) => {
appShell?.style.setProperty('--keyboard-offset', `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`);
};
const syncKeyboardUi = () => {
const viewport = window.visualViewport || null;
const viewportHeight = Math.max(viewport?.height || 0, window.innerHeight || 0);
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
const keyboardInset = viewportShrunk
? Math.max(0, baseViewportHeight - viewportHeight)
: 0;
setKeyboardInset(keyboardInset);
setChatKeyboardOpen(inputFocused && viewportShrunk);
if (viewportShrunk && window.scrollY !== 0) {
window.scrollTo(0, 0);
}
if (inputFocused) {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
}
};
const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading;
@@ -1235,20 +1260,21 @@ export function render({ navigate, route, chrome }) {
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
cancelEditMode({ restoreDraft: true });
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
};
const sendTextMessage = async (rawText) => {
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
const text = safeText.trim();
if (!text) return;
hideUnreadSeparator({ rerender: false });
const editing = activeEdit;
const replying = !editing ? activeReply : null;
const finalText = editing
? `${String(editing?.prefixText || '')}${text}`
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try {
@@ -1288,7 +1314,7 @@ export function render({ navigate, route, chrome }) {
cancelReplyMode({ restoreDraft: false });
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
if (localRevisionApplied) {
notifyUnreadStateUpdated();
}
@@ -1331,7 +1357,7 @@ export function render({ navigate, route, chrome }) {
error: e?.message || 'unknown',
},
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
@@ -1393,7 +1419,7 @@ export function render({ navigate, route, chrome }) {
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
historyBootstrapped = true;
renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
if (preserveScroll) {
window.requestAnimationFrame(() => {
const nextHeight = Number(scrollContainer?.scrollHeight || 0);
@@ -1445,12 +1471,12 @@ export function render({ navigate, route, chrome }) {
input?.addEventListener('focus', () => {
rememberEmojiSelection();
inputFocused = true;
syncKeyboardUi();
scrollToLatestMessage(log);
window.requestAnimationFrame(() => {
if (inputFocused) scrollToLatestMessage(log);
});
});
input?.addEventListener('blur', () => {
inputFocused = false;
setChatKeyboardOpen(false);
});
emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => {
@@ -1493,25 +1519,34 @@ export function render({ navigate, route, chrome }) {
const handleIncomingChatRefresh = async (event) => {
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
if (updatedChatId !== chatId) return;
if (Number(event?.detail?.messageType || 0) === 1) {
if (!unreadSeparatorVisible) {
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
}
scheduleUnreadSeparatorAutoHide();
}
preserveComposerSelection(input, () => {
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
renderChatLog({ scrollMode: 'latest' });
});
window.requestAnimationFrame(() => scrollToLatestMessage(log));
void sendReadReceiptsForVisible(chatId);
};
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi);
chrome?.setComposer(form);
wrap.append(historyLoader, log);
screen.append(wrap);
chrome?.setComposer(form);
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
showUnreadSeparator: unreadSeparatorVisible,
});
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
window.setTimeout(() => {
if (markChatRead(chatId) > 0) {
notifyUnreadStateUpdated();
@@ -1519,18 +1554,18 @@ export function render({ navigate, route, chrome }) {
}, 220);
void sendReadReceiptsForVisible(chatId);
window.requestAnimationFrame(() => {
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap;
boundScrollContainer = wrap;
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
void loadHistoryPage({ preserveScroll: true });
});
screen.cleanup = () => {
setChatKeyboardOpen(false);
setKeyboardInset(0);
hideUnreadSeparator({ rerender: false });
stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
window.removeEventListener('resize', syncKeyboardUi);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
clearUnreadSeparatorHideTimer();
document.body.classList.remove('chat-topbar-overlay');
chrome?.setComposer(null);
};
return screen;
}
+115 -3
View File
@@ -82,6 +82,15 @@ function formatChatRowTime(ts) {
}).format(new Date(value));
}
function compareChatRows(a, b) {
const timeA = Number(a?.lastTimeMs || 0);
const timeB = Number(b?.lastTimeMs || 0);
if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.name || '').toLowerCase();
const nameB = String(b?.name || '').toLowerCase();
return nameA.localeCompare(nameB, 'ru');
}
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
export function render({ navigate, chrome }) {
@@ -99,11 +108,103 @@ export function render({ navigate, chrome }) {
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
<div class="dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn" aria-label="Меню контактов" aria-haspopup="menu" aria-expanded="false">
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск контактов</span>
</button>
</div>
</div>
`;
const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login;
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
const menuWrap = head.querySelector('.dm-head-menu-wrap');
const menuButton = head.querySelector('.dm-head-menu-btn');
const menuTemplate = head.querySelector('.dm-head-menu');
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
// header bounds. A dropdown overflowing below it can look visible but clicks may
// land on the content layer underneath. Render the open menu as a body portal.
menuTemplate?.remove();
let menuPortal = null;
const closeHeadMenu = () => {
menuPortal?.remove();
menuPortal = null;
menuButton?.setAttribute('aria-expanded', 'false');
menuWrap?.classList.remove('is-open');
};
const positionHeadMenu = () => {
if (!menuPortal || !menuButton) return;
const rect = menuButton.getBoundingClientRect();
const margin = 10;
const menuWidth = menuPortal.offsetWidth || 206;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
menuPortal.style.left = `${Math.round(left)}px`;
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
};
const openHeadMenu = () => {
if (!menuButton || menuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск контактов</span>
</button>
`;
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
closeHeadMenu();
navigate('contact-search-view');
});
portal.addEventListener('click', (event) => event.stopPropagation());
document.body.append(portal);
menuPortal = portal;
menuButton.setAttribute('aria-expanded', 'true');
menuWrap?.classList.add('is-open');
positionHeadMenu();
};
menuButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (menuPortal) closeHeadMenu();
else openHeadMenu();
});
const onOutsideClick = (event) => {
if (!menuPortal) return;
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
closeHeadMenu();
};
const onMenuKeydown = (event) => {
if (event.key !== 'Escape' || !menuPortal) return;
closeHeadMenu();
menuButton?.focus();
};
const onMenuViewportChange = () => positionHeadMenu();
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onMenuKeydown);
window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const divider = document.createElement('div');
divider.className = 'dm-divider';
@@ -175,6 +276,7 @@ export function render({ navigate, chrome }) {
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: false,
lastTimeMs,
};
});
@@ -197,10 +299,11 @@ export function render({ navigate, chrome }) {
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: true,
lastTimeMs,
};
});
const rows = [...contactRows, ...extraRows];
const rows = [...contactRows, ...extraRows].sort(compareChatRows);
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
@@ -251,5 +354,14 @@ export function render({ navigate, chrome }) {
chrome?.setTopbar(head);
screen.append(divider, list);
loadList();
screen.cleanup = () => {
closeHeadMenu();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onMenuKeydown);
window.removeEventListener('resize', onMenuViewportChange);
window.removeEventListener('scroll', onMenuViewportChange, true);
};
return screen;
}