Merge remote-tracking branch 'origin/main' into расширение-функционала-каналов

This commit is contained in:
AidarKC
2026-08-20 08:14:52 +04:00
6 changed files with 529 additions and 104 deletions
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-visual"
/> />
<base href="/" /> <base href="/" />
<link rel="manifest" href="./manifest.webmanifest" /> <link rel="manifest" href="./manifest.webmanifest" />
@@ -12,7 +12,7 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" /> <link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title> <title>СИЯНИЕ</title>
<script> <script>
window.__SHINE_BUILD_HASH__ = '20260806223040'; window.__SHINE_BUILD_HASH__ = '20260819190000';
window.__SHINE_CLIENT_VERSION__ = '1.2.10'; window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script> </script>
<script> <script>
+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 solanaRpcCheckView from './pages/solana-rpc-check-view.js';
import * as messagesList from './pages/messages-list.js'; import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.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 userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js'; import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js'; import * as channelView from './pages/channel-view.js';
@@ -221,6 +221,62 @@ function setKeyboardOffsetPx(valuePx = 0) {
setShellMetricVar('--keyboard-offset', valuePx); 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) { function attachSlotHeightObserver(slotEl, cssVarName) {
if (!slotEl || typeof ResizeObserver !== 'function') return null; if (!slotEl || typeof ResizeObserver !== 'function') return null;
const sync = () => { const sync = () => {
@@ -237,6 +293,55 @@ const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height
const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height'); const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height');
const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-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) { function clearSlot(slotEl, cssVarName) {
if (!slotEl) return; if (!slotEl) return;
slotEl.innerHTML = ''; slotEl.innerHTML = '';
+89 -54
View File
@@ -572,12 +572,27 @@ function scrollToUnreadSeparator(list) {
return true; 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 = ''; list.innerHTML = '';
const messages = getChatMessages(chatId); const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false; let unreadSeparatorInserted = false;
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
messages.forEach((msg) => { 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'); const sep = document.createElement('div');
sep.className = 'chat-unread-separator'; sep.className = 'chat-unread-separator';
const label = document.createElement('span'); 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 }) { export function render({ navigate, route, chrome }) {
document.body.classList.add('chat-topbar-overlay');
const routeChatId = route.params.chatId || 'u1'; const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1'; const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || { 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 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 hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread); const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
let historyHasMore = true; let historyHasMore = true;
let historyLoading = false; let historyLoading = false;
let historyNextBeforeTimeMs = 0; let historyNextBeforeTimeMs = 0;
let historyNextBeforeMessageKey = ''; let historyNextBeforeMessageKey = '';
let historyBootstrapped = false; let historyBootstrapped = false;
let boundScrollContainer = null; 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) => { const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) { if (!isTextToSpeechConfigured(state.entrySettings)) {
@@ -819,13 +871,13 @@ export function render({ navigate, route, chrome }) {
const handleStartCall = async (mode = 'audio') => { const handleStartCall = async (mode = 'audio') => {
try { try {
await startOutgoingCall(chatId, { mode }); await startOutgoingCall(chatId, { mode });
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
} catch (e) { } catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out', from: 'out',
kind: 'call-tech', 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, unread: false,
rawBlobB64: String(result?.localBlobB64 || ''), rawBlobB64: String(result?.localBlobB64 || ''),
}); });
renderLog(log, chatId, { renderChatLog({ scrollMode: 'latest', markAsRead: false });
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: 'latest',
});
notifyUnreadStateUpdated(); notifyUnreadStateUpdated();
}; };
@@ -1010,29 +1058,6 @@ export function render({ navigate, route, chrome }) {
let inputFocused = false; let inputFocused = false;
let emojiPickerOpen = false; let emojiPickerOpen = false;
let emojiSelection = null; 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) => { const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading; historyLoader.hidden = !isLoading;
@@ -1235,20 +1260,21 @@ export function render({ navigate, route, chrome }) {
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) { if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
cancelEditMode({ restoreDraft: true }); cancelEditMode({ restoreDraft: true });
} }
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
}; };
const sendTextMessage = async (rawText) => { const sendTextMessage = async (rawText) => {
const safeText = sanitizeUserDmTextForSend(String(rawText || '')); const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
const text = safeText.trim(); const text = safeText.trim();
if (!text) return; if (!text) return;
hideUnreadSeparator({ rerender: false });
const editing = activeEdit; const editing = activeEdit;
const replying = !editing ? activeReply : null; const replying = !editing ? activeReply : null;
const finalText = editing const finalText = editing
? `${String(editing?.prefixText || '')}${text}` ? `${String(editing?.prefixText || '')}${text}`
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`; : `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text); const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try { try {
@@ -1288,7 +1314,7 @@ export function render({ navigate, route, chrome }) {
cancelReplyMode({ restoreDraft: false }); cancelReplyMode({ restoreDraft: false });
} }
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderChatLog({ scrollMode: 'latest', markAsRead: false });
if (localRevisionApplied) { if (localRevisionApplied) {
notifyUnreadStateUpdated(); notifyUnreadStateUpdated();
} }
@@ -1331,7 +1357,7 @@ export function render({ navigate, route, chrome }) {
error: e?.message || 'unknown', 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); historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim(); historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
historyBootstrapped = true; historyBootstrapped = true;
renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' }); renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
if (preserveScroll) { if (preserveScroll) {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const nextHeight = Number(scrollContainer?.scrollHeight || 0); const nextHeight = Number(scrollContainer?.scrollHeight || 0);
@@ -1445,12 +1471,12 @@ export function render({ navigate, route, chrome }) {
input?.addEventListener('focus', () => { input?.addEventListener('focus', () => {
rememberEmojiSelection(); rememberEmojiSelection();
inputFocused = true; inputFocused = true;
syncKeyboardUi(); window.requestAnimationFrame(() => {
scrollToLatestMessage(log); if (inputFocused) scrollToLatestMessage(log);
});
}); });
input?.addEventListener('blur', () => { input?.addEventListener('blur', () => {
inputFocused = false; inputFocused = false;
setChatKeyboardOpen(false);
}); });
emojiToggle?.setAttribute('aria-expanded', 'false'); emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => { emojiToggle?.addEventListener('pointerdown', (event) => {
@@ -1493,25 +1519,34 @@ export function render({ navigate, route, chrome }) {
const handleIncomingChatRefresh = async (event) => { const handleIncomingChatRefresh = async (event) => {
const updatedChatId = normalizeDmChatId(event?.detail?.chatId); const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
if (updatedChatId !== chatId) return; 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, () => { preserveComposerSelection(input, () => {
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' }); renderChatLog({ scrollMode: 'latest' });
}); });
window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.requestAnimationFrame(() => scrollToLatestMessage(log));
void sendReadReceiptsForVisible(chatId); void sendReadReceiptsForVisible(chatId);
}; };
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi);
chrome?.setComposer(form);
wrap.append(historyLoader, log); wrap.append(historyLoader, log);
screen.append(wrap); screen.append(wrap);
chrome?.setComposer(form);
renderLog(log, chatId, { renderLog(log, chatId, {
onOpenActions: handleOpenActions, onOpenActions: handleOpenActions,
markAsRead: false, markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest', scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
showUnreadSeparator: unreadSeparatorVisible,
}); });
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
window.setTimeout(() => { window.setTimeout(() => {
if (markChatRead(chatId) > 0) { if (markChatRead(chatId) > 0) {
notifyUnreadStateUpdated(); notifyUnreadStateUpdated();
@@ -1519,18 +1554,18 @@ export function render({ navigate, route, chrome }) {
}, 220); }, 220);
void sendReadReceiptsForVisible(chatId); void sendReadReceiptsForVisible(chatId);
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap; boundScrollContainer = wrap;
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true }); boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
void loadHistoryPage({ preserveScroll: true }); void loadHistoryPage({ preserveScroll: true });
}); });
screen.cleanup = () => { screen.cleanup = () => {
setChatKeyboardOpen(false); hideUnreadSeparator({ rerender: false });
setKeyboardInset(0);
stopAllTwemojiAnimations(); stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
window.removeEventListener('resize', syncKeyboardUi);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
clearUnreadSeparatorHideTimer();
document.body.classList.remove('chat-topbar-overlay');
chrome?.setComposer(null);
}; };
return screen; return screen;
} }
+115 -3
View File
@@ -82,6 +82,15 @@ function formatChatRowTime(ts) {
}).format(new Date(value)); }).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>'; 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 }) { export function render({ navigate, chrome }) {
@@ -99,11 +108,103 @@ export function render({ navigate, chrome }) {
</div> </div>
</div> </div>
<h1 class="dm-head-title">Контакты</h1> <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'); const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login; 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'); const divider = document.createElement('div');
divider.className = 'dm-divider'; divider.className = 'dm-divider';
@@ -175,6 +276,7 @@ export function render({ navigate, chrome }) {
time: formatChatRowTime(lastTimeMs), time: formatChatRowTime(lastTimeMs),
unread, unread,
notInContacts: false, notInContacts: false,
lastTimeMs,
}; };
}); });
@@ -197,10 +299,11 @@ export function render({ navigate, chrome }) {
time: formatChatRowTime(lastTimeMs), time: formatChatRowTime(lastTimeMs),
unread, unread,
notInContacts: true, notInContacts: true,
lastTimeMs,
}; };
}); });
const rows = [...contactRows, ...extraRows]; const rows = [...contactRows, ...extraRows].sort(compareChatRows);
if (!rows.length) { if (!rows.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'card meta-muted';
@@ -251,5 +354,14 @@ export function render({ navigate, chrome }) {
chrome?.setTopbar(head); chrome?.setTopbar(head);
screen.append(divider, list); screen.append(divider, list);
loadList(); loadList();
screen.cleanup = () => {
closeHeadMenu();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onMenuKeydown);
window.removeEventListener('resize', onMenuViewportChange);
window.removeEventListener('scroll', onMenuViewportChange, true);
};
return screen; return screen;
} }
+164 -22
View File
@@ -6268,6 +6268,37 @@ html, body { overflow-x: hidden; }
gap: 12px; gap: 12px;
} }
.dm-chat-screen {
display: flex;
flex-direction: column;
min-height: 0;
gap: 12px;
}
.dm-chat-screen > .dm-chat-wrap {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.dm-chat-composer {
position: sticky;
bottom: 0;
z-index: 12;
flex: 0 0 auto;
}
.screen-content:has(> .dm-chat-screen) {
display: flex;
flex-direction: column;
overflow: hidden;
}
.screen-content:has(> .dm-chat-screen) > .dm-chat-screen {
flex: 1 1 auto;
min-height: 0;
}
.screen-content:has(> .dm-screen) { .screen-content:has(> .dm-screen) {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
@@ -6282,27 +6313,14 @@ html, body { overflow-x: hidden; }
.screen-content:has(> .dm-chat-screen) { .screen-content:has(> .dm-chat-screen) {
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
scrollbar-width: thin; scrollbar-width: none;
scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06); -ms-overflow-style: none;
} }
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar { .screen-content:has(> .dm-chat-screen)::-webkit-scrollbar {
width: 4px; width: 0;
height: 4px; height: 0;
display: block; display: none;
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.7);
border-radius: 999px;
}
.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb:hover {
background: rgba(240, 198, 76, 0.9);
} }
.dm-messages-log { .dm-messages-log {
@@ -6401,11 +6419,10 @@ html, body { overflow-x: hidden; }
gap: 10px; gap: 10px;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: end; align-items: end;
position: sticky; position: relative;
bottom: 0;
z-index: 10; z-index: 10;
margin-inline: -14px; margin-inline: 0;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); padding: 10px 0;
border-top: 1px solid rgba(212, 175, 55, 0.22); border-top: 1px solid rgba(212, 175, 55, 0.22);
background: rgba(8, 12, 20, 0.9); background: rgba(8, 12, 20, 0.9);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
@@ -7900,3 +7917,128 @@ html, body { overflow-x: hidden; }
color: #D4AF37; color: #D4AF37;
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
} }
/* Contacts header overflow menu — vertical ellipsis + glass dropdown. */
.dm-head-menu-wrap {
position: relative;
justify-self: end;
display: grid;
place-items: center;
z-index: 40;
}
.dm-head-menu-btn {
width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 16px;
display: grid;
place-items: center;
color: #FFD98A;
background: transparent;
cursor: pointer;
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.dm-head-menu-btn:active { transform: scale(0.94); }
.dm-head-menu-wrap.is-open .dm-head-menu-btn,
.dm-head-menu-btn:focus-visible {
outline: none;
background: rgba(240, 184, 46, 0.08);
box-shadow: inset 0 0 0 1px rgba(240, 184, 46, 0.24), 0 0 18px rgba(240, 184, 46, 0.12);
}
.dm-head-menu-dots {
width: 6px;
height: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3.5px;
}
.dm-head-menu-dots i {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 7px rgba(240, 184, 46, 0.45);
}
.dm-head-menu {
position: absolute;
top: calc(100% + 7px);
right: 0;
width: max-content;
min-width: 190px;
padding: 6px;
border: 1px solid rgba(240, 184, 46, 0.26);
border-radius: 15px;
background: linear-gradient(155deg, rgba(22, 24, 31, 0.97), rgba(10, 12, 18, 0.97));
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.42), 0 0 20px rgba(240, 184, 46, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
transform-origin: top right;
animation: dm-head-menu-in 140ms ease-out both;
}
.dm-head-menu[hidden] { display: none; }
.dm-head-menu::before {
content: '';
position: absolute;
top: -5px;
right: 18px;
width: 9px;
height: 9px;
transform: rotate(45deg);
border-left: 1px solid rgba(240, 184, 46, 0.22);
border-top: 1px solid rgba(240, 184, 46, 0.22);
background: rgba(19, 21, 28, 0.98);
}
.dm-head-menu-item {
position: relative;
z-index: 1;
width: 100%;
min-height: 44px;
padding: 0 12px;
border: 0;
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
color: #FCEAC0;
background: transparent;
font: inherit;
font-size: 14px;
font-weight: 600;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.dm-head-menu-item svg {
width: 19px;
height: 19px;
flex: 0 0 auto;
color: #E7B83D;
filter: drop-shadow(0 0 5px rgba(240, 184, 46, 0.22));
}
.dm-head-menu-item:hover,
.dm-head-menu-item:focus-visible,
.dm-head-menu-item:active {
outline: none;
background: rgba(240, 184, 46, 0.09);
}
@keyframes dm-head-menu-in {
from { opacity: 0; transform: translateY(-5px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.dm-head-menu { animation: none; }
.dm-head-menu-btn { transition: none; }
}
/* Contacts overflow menu body portal: avoids topbar overflow hit-testing on desktop/Android. */
.dm-head-menu--portal {
position: fixed;
right: auto;
z-index: 10000;
pointer-events: auto;
isolation: isolate;
}
+53 -22
View File
@@ -2,7 +2,7 @@ body {
display: flex; display: flex;
justify-content: center; justify-content: center;
background: #05070A; background: #05070A;
min-height: 100vh; min-height: 100dvh;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@@ -22,12 +22,11 @@ body::before {
} }
.app-shell { .app-shell {
width: min(100vw, 430px); width: min(var(--app-viewport-width, 100vw), 430px);
height: 100vh; height: var(--app-viewport-height, 100vh);
height: 100svh;
position: fixed; position: fixed;
top: 0; top: var(--app-viewport-offset-top, 0px);
left: 50%; left: calc(50% + var(--app-viewport-offset-left, 0px) / 2);
transform: translateX(-50%); transform: translateX(-50%);
--call-minimized-bar-height: 0px; --call-minimized-bar-height: 0px;
--topbar-height: 0px; --topbar-height: 0px;
@@ -94,10 +93,11 @@ body::before {
.composer-slot { .composer-slot {
position: absolute; position: absolute;
left: 0; left: 50%;
right: 0; width: min(var(--app-viewport-width, 100vw), 430px);
transform: translateX(-50%);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom)); bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
padding: 0 12px 8px; padding: 0 12px;
pointer-events: none; pointer-events: none;
} }
@@ -115,20 +115,42 @@ body::before {
transition: opacity 0.18s ease, transform 0.18s ease; transition: opacity 0.18s ease, transform 0.18s ease;
} }
body.chat-keyboard-open .screen-content { body.chat-topbar-overlay .topbar-slot {
bottom: calc(var(--composer-height, 0px) + max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px))); position: fixed;
padding-bottom: calc(14px + env(safe-area-inset-bottom)); top: var(--call-minimized-bar-height, 0px);
} left: 0;
right: 0;
body.chat-keyboard-open .composer-slot { width: min(100vw, 430px);
bottom: max(var(--toolbar-height, 78px), var(--keyboard-offset, 0px)); margin: 0 auto;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
body.chat-keyboard-open .toolbar-slot {
opacity: 1;
pointer-events: auto;
transform: none; transform: none;
padding: 0 12px;
}
body.chat-topbar-overlay .screen-content {
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
}
body.chat-topbar-overlay .composer-slot {
position: absolute;
left: 50%;
width: min(var(--app-viewport-width, 100vw), 430px);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
}
/* При открытой клавиатуре toolbar остаётся на физическом дне экрана и
перекрывается клавиатурой. Composer прижимается ровно к верхней границе
visualViewport, а область сообщений заканчивается прямо над composer. */
.app-shell.keyboard-open .toolbar-slot {
bottom: 0;
}
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
bottom: var(--keyboard-offset, 0px);
}
body.chat-topbar-overlay .app-shell.keyboard-open .screen-content {
bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px));
} }
.connection-retry-banner { .connection-retry-banner {
@@ -177,3 +199,12 @@ body.chat-keyboard-open .toolbar-slot {
border-radius: 24px; border-radius: 24px;
} }
} }
/* Android keyboard: composer touches the keyboard edge; only the composer moves. */
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
padding-bottom: 0;
}
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot .dm-chat-input {
padding-bottom: 0;
}