UI: упростить профиль и обновить UX чатов/шапок
This commit is contained in:
parent
83892d5093
commit
c6d310184b
@ -0,0 +1,26 @@
|
|||||||
|
# Профиль: упрощение + чат: UX меню и голосовой ввод
|
||||||
|
|
||||||
|
- Краткое описание:
|
||||||
|
- В `profile-view` убрана кнопка `Обновить` и статусная строка (`Профиль обновлён`/ошибки).
|
||||||
|
- Кнопка `Изменить профиль` переименована в `Редактировать профиль`.
|
||||||
|
- В личном чате обновлены UX-сценарии:
|
||||||
|
- контекстное меню на сообщении (`Копировать`, `Прочесть`) с закрытием кликом вне меню;
|
||||||
|
- тост `Сообщение скопированно` при копировании;
|
||||||
|
- обновлённый модал голосового ввода (`Отмена`, `OK`, `Распознать и сразу отправить сообщение`);
|
||||||
|
- фоновое распознавание и авто-отправка для сценария «сразу отправить»;
|
||||||
|
- для `OK` отображается режим ожидания распознавания с последующей вставкой текста в поле ввода.
|
||||||
|
|
||||||
|
- Что проверять:
|
||||||
|
- На вкладке профиля отсутствуют кнопка `Обновить` и зелёный статус `Профиль обновлён`.
|
||||||
|
- Кнопка вверху профиля называется `Редактировать профиль`.
|
||||||
|
- В чате по клику на сообщение открывается компактное меню с двумя пунктами.
|
||||||
|
- Копирование текста сообщения работает и показывает короткий тост.
|
||||||
|
- Прочтение сообщения вслух запускается сразу.
|
||||||
|
- Голосовой ввод корректно работает в двух режимах: вставка текста и авто-отправка после распознавания.
|
||||||
|
|
||||||
|
- Ожидаемый результат:
|
||||||
|
- Профиль выглядит чище, без лишних статусов и ручной перезагрузки.
|
||||||
|
- В личных сообщениях управление сообщениями и голосовым вводом работает стабильно и предсказуемо.
|
||||||
|
|
||||||
|
- Статус:
|
||||||
|
- `pending`
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.70
|
client.version=1.2.71
|
||||||
server.version=1.2.64
|
server.version=1.2.65
|
||||||
|
|||||||
@ -19,35 +19,39 @@ function showSttMissingConfigDialog(navigate) {
|
|||||||
if (goSettings) navigate('tools-settings-view');
|
if (goSettings) navigate('tools-settings-view');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSpeechInputModal({ navigate, onTextReady }) {
|
export async function openSpeechInputModal({ navigate, onTextReady, onSendText, onSendQueued }) {
|
||||||
if (!isSpeechToTextConfigured(state.entrySettings)) {
|
if (!isSpeechToTextConfigured(state.entrySettings)) {
|
||||||
showSttMissingConfigDialog(navigate);
|
showSttMissingConfigDialog(navigate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
root.innerHTML = `
|
const host = document.createElement('div');
|
||||||
<div class="modal" id="speech-input-modal">
|
host.innerHTML = `
|
||||||
|
<div class="modal" id="speech-input-modal-layer">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<h3 class="modal-title">Голосовой ввод</h3>
|
<h3 class="modal-title">Голосовой ввод</h3>
|
||||||
<p class="meta-muted" id="speech-input-status">Идёт запись...</p>
|
<p class="meta-muted" id="speech-input-status">Идёт запись...</p>
|
||||||
<div class="voice-level-wrap"><div class="voice-level-fill" id="speech-level-fill"></div></div>
|
<div class="voice-level-wrap"><div class="voice-level-fill" id="speech-level-fill"></div></div>
|
||||||
<p class="meta-muted" id="speech-input-time">00:00</p>
|
<p class="meta-muted" id="speech-input-time">00:00</p>
|
||||||
<p class="inline-error" id="speech-input-error"></p>
|
<p class="inline-error" id="speech-input-error"></p>
|
||||||
<div class="form-actions-grid">
|
<div class="speech-actions-top">
|
||||||
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
|
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
|
||||||
<button class="primary-btn" type="button" id="speech-ok">OK</button>
|
<button class="primary-btn" type="button" id="speech-ok">OK</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="primary-btn speech-send-now-btn" type="button" id="speech-send-now">Распознать и сразу отправить сообщение</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
root.append(host);
|
||||||
|
|
||||||
const statusEl = root.querySelector('#speech-input-status');
|
const statusEl = host.querySelector('#speech-input-status');
|
||||||
const timeEl = root.querySelector('#speech-input-time');
|
const timeEl = host.querySelector('#speech-input-time');
|
||||||
const levelEl = root.querySelector('#speech-level-fill');
|
const levelEl = host.querySelector('#speech-level-fill');
|
||||||
const errorEl = root.querySelector('#speech-input-error');
|
const errorEl = host.querySelector('#speech-input-error');
|
||||||
const cancelBtn = root.querySelector('#speech-cancel');
|
const cancelBtn = host.querySelector('#speech-cancel');
|
||||||
const okBtn = root.querySelector('#speech-ok');
|
const sendNowBtn = host.querySelector('#speech-send-now');
|
||||||
|
const okBtn = host.querySelector('#speech-ok');
|
||||||
const recorder = createMicrophoneRecorder();
|
const recorder = createMicrophoneRecorder();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
@ -55,14 +59,16 @@ export async function openSpeechInputModal({ navigate, onTextReady }) {
|
|||||||
const close = () => {
|
const close = () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
root.innerHTML = '';
|
host.remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setBusy = (flag) => {
|
const setBusy = (flag) => {
|
||||||
busy = !!flag;
|
busy = !!flag;
|
||||||
cancelBtn.disabled = busy;
|
cancelBtn.disabled = busy;
|
||||||
|
sendNowBtn.disabled = busy;
|
||||||
okBtn.disabled = busy;
|
okBtn.disabled = busy;
|
||||||
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
|
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
|
||||||
|
sendNowBtn.textContent = busy ? 'Распознаю...' : 'Распознать и сразу отправить сообщение';
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -84,10 +90,16 @@ export async function openSpeechInputModal({ navigate, onTextReady }) {
|
|||||||
okBtn.addEventListener('click', async () => {
|
okBtn.addEventListener('click', async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
errorEl.textContent = '';
|
|
||||||
statusEl.textContent = 'Распознаю речь...';
|
|
||||||
try {
|
try {
|
||||||
const audioBlob = await recorder.stop();
|
const audioBlob = await recorder.stop();
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="modal" id="speech-input-modal-layer">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">Голосовой ввод</h3>
|
||||||
|
<p class="meta-muted">Идёт распознавание текста...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
||||||
if (typeof onTextReady === 'function') onTextReady(text);
|
if (typeof onTextReady === 'function') onTextReady(text);
|
||||||
close();
|
close();
|
||||||
@ -97,4 +109,24 @@ export async function openSpeechInputModal({ navigate, onTextReady }) {
|
|||||||
errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`;
|
errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sendNowBtn.addEventListener('click', async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const audioBlob = await recorder.stop();
|
||||||
|
close();
|
||||||
|
if (typeof onSendQueued === 'function') onSendQueued();
|
||||||
|
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
||||||
|
if (typeof onSendText === 'function') {
|
||||||
|
await onSendText(text);
|
||||||
|
} else if (typeof onTextReady === 'function') {
|
||||||
|
onTextReady(text);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setBusy(false);
|
||||||
|
close();
|
||||||
|
window.alert(`Ошибка распознавания: ${error?.message || 'unknown'}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,6 +207,27 @@ function buildThreadRouteFromTarget(target, selector) {
|
|||||||
].join('/');
|
].join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
|
||||||
|
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
||||||
|
return [
|
||||||
|
'channel',
|
||||||
|
encodeRoutePart(selector.short.ownerBlockchainName),
|
||||||
|
encodeRoutePart(selector.short.channelName),
|
||||||
|
].join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerBch = String(selector?.channel?.ownerBlockchainName || '').trim();
|
||||||
|
const label = String(resolvedChannelLabel || '').trim();
|
||||||
|
const slashIndex = label.indexOf('/');
|
||||||
|
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : '';
|
||||||
|
if (!ownerBch || !channelName) return '';
|
||||||
|
return [
|
||||||
|
'channel',
|
||||||
|
encodeRoutePart(ownerBch),
|
||||||
|
encodeRoutePart(channelName),
|
||||||
|
].join('/');
|
||||||
|
}
|
||||||
|
|
||||||
function buildTargetFromNode(node) {
|
function buildTargetFromNode(node) {
|
||||||
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
||||||
const blockNumber = Number(node?.messageRef?.blockNumber);
|
const blockNumber = Number(node?.messageRef?.blockNumber);
|
||||||
@ -315,6 +336,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack thread-node-card channel-message-card';
|
card.className = 'card stack thread-node-card channel-message-card';
|
||||||
|
card.classList.add('is-counters-visible');
|
||||||
|
|
||||||
const author = node?.authorLogin || 'автор';
|
const author = node?.authorLogin || 'автор';
|
||||||
const text = resolveNodeText(node) || '(пусто)';
|
const text = resolveNodeText(node) || '(пусто)';
|
||||||
@ -532,9 +554,16 @@ export function render({ navigate, route }) {
|
|||||||
const appScreen = document.getElementById('app-screen');
|
const appScreen = document.getElementById('app-screen');
|
||||||
appScreen?.classList.add('channels-scroll-clean');
|
appScreen?.classList.add('channels-scroll-clean');
|
||||||
|
|
||||||
const channelIndicator = document.createElement('div');
|
const header = renderHeader({
|
||||||
channelIndicator.className = 'card channels-user-chip';
|
title: '',
|
||||||
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
leftAction: { label: '<', onClick: () => navigateBack() },
|
||||||
|
rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }],
|
||||||
|
});
|
||||||
|
const threadHeaderButton = header.querySelector('.header-actions .icon-btn');
|
||||||
|
if (threadHeaderButton) {
|
||||||
|
threadHeaderButton.classList.add('channel-header-route-btn');
|
||||||
|
threadHeaderButton.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
const statusBox = document.createElement('div');
|
const statusBox = document.createElement('div');
|
||||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||||
@ -643,13 +672,7 @@ export function render({ navigate, route }) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
screen.append(
|
screen.append(header, statusBox);
|
||||||
renderHeader({
|
|
||||||
title: 'Тред',
|
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
screen.append(channelIndicator, statusBox);
|
|
||||||
|
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
const invalid = document.createElement('div');
|
const invalid = document.createElement('div');
|
||||||
@ -758,7 +781,18 @@ export function render({ navigate, route }) {
|
|||||||
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
||||||
}
|
}
|
||||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||||
channelIndicator.textContent = `Канал: ${resolvedChannelLabel || fallbackChannel}`;
|
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||||
|
if (threadHeaderButton) {
|
||||||
|
threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`;
|
||||||
|
threadHeaderButton.disabled = false;
|
||||||
|
threadHeaderButton.onclick = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel);
|
||||||
|
if (routeToChannel) navigate(routeToChannel);
|
||||||
|
else navigate('channels-list');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
const nextNumber = () => {
|
const nextNumber = () => {
|
||||||
@ -766,8 +800,9 @@ export function render({ navigate, route }) {
|
|||||||
return seq;
|
return seq;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let ancestorsWrap = null;
|
||||||
if (ancestors.length) {
|
if (ancestors.length) {
|
||||||
const ancestorsWrap = document.createElement('div');
|
ancestorsWrap = document.createElement('div');
|
||||||
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||||
const title = document.createElement('h3');
|
const title = document.createElement('h3');
|
||||||
title.className = 'section-title';
|
title.className = 'section-title';
|
||||||
@ -776,18 +811,17 @@ export function render({ navigate, route }) {
|
|||||||
ancestors.forEach((node, index) => {
|
ancestors.forEach((node, index) => {
|
||||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
||||||
});
|
});
|
||||||
screen.append(ancestorsWrap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let focusWrap = null;
|
||||||
if (focus) {
|
if (focus) {
|
||||||
const focusWrap = document.createElement('div');
|
focusWrap = document.createElement('div');
|
||||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||||
const focusTitle = document.createElement('h3');
|
const focusTitle = document.createElement('h3');
|
||||||
focusTitle.className = 'section-title';
|
focusTitle.className = 'section-title';
|
||||||
focusTitle.textContent = 'Текущее сообщение';
|
focusTitle.textContent = 'Текущее сообщение';
|
||||||
focusWrap.append(focusTitle);
|
focusWrap.append(focusTitle);
|
||||||
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
||||||
screen.append(focusWrap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const descendantsWrap = document.createElement('div');
|
const descendantsWrap = document.createElement('div');
|
||||||
@ -806,8 +840,23 @@ export function render({ navigate, route }) {
|
|||||||
descendantsWrap.append(empty);
|
descendantsWrap.append(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ancestorsWrap) {
|
||||||
|
screen.append(ancestorsWrap);
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'thread-history-divider';
|
||||||
|
screen.append(divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusWrap) screen.append(focusWrap);
|
||||||
screen.append(descendantsWrap);
|
screen.append(descendantsWrap);
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey);
|
applyPendingScroll(screen, routeKey);
|
||||||
|
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
||||||
|
if (!hasPendingScroll && focusWrap) {
|
||||||
|
setTimeout(() => {
|
||||||
|
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||||
|
}, 20);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
const failed = document.createElement('div');
|
const failed = document.createElement('div');
|
||||||
|
|||||||
@ -871,7 +871,7 @@ export function render({ navigate, route }) {
|
|||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
leftAction: { label: '<', onClick: () => navigateBack() },
|
||||||
rightActions: [{ label: 'Канал', onClick: () => {} }],
|
rightActions: [{ label: 'Канал: ...', onClick: () => {} }],
|
||||||
});
|
});
|
||||||
const channelHeaderButton = header.querySelector('.header-actions .icon-btn');
|
const channelHeaderButton = header.querySelector('.header-actions .icon-btn');
|
||||||
if (channelHeaderButton) {
|
if (channelHeaderButton) {
|
||||||
@ -987,7 +987,7 @@ export function render({ navigate, route }) {
|
|||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
activeSelector = apiData?.selector || null;
|
activeSelector = apiData?.selector || null;
|
||||||
const channelRouteLabel = `${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`;
|
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`;
|
||||||
if (channelHeaderButton) {
|
if (channelHeaderButton) {
|
||||||
channelHeaderButton.textContent = channelRouteLabel;
|
channelHeaderButton.textContent = channelRouteLabel;
|
||||||
channelHeaderButton.disabled = false;
|
channelHeaderButton.disabled = false;
|
||||||
|
|||||||
@ -10,14 +10,108 @@ import {
|
|||||||
markOutgoingSent,
|
markOutgoingSent,
|
||||||
markReadReceiptSentByBaseKey,
|
markReadReceiptSentByBaseKey,
|
||||||
authService,
|
authService,
|
||||||
|
setContacts,
|
||||||
state,
|
state,
|
||||||
} 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 { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||||
|
import { showToast } from '../services/channels-ux.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
|
|
||||||
|
function openMessageActionsModal({ messageText = '', onReadAloud }) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="chat-message-actions-modal-overlay">
|
||||||
|
<div class="modal-card stack dm-dialog-card dm-message-actions-menu" id="chat-message-actions-modal">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
root.innerHTML = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
root.querySelector('#chat-message-actions-modal-overlay')?.addEventListener('click', (event) => {
|
||||||
|
if (event.target?.id === 'chat-message-actions-modal-overlay') close();
|
||||||
|
});
|
||||||
|
root.querySelector('#msg-action-copy')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(String(messageText || ''));
|
||||||
|
}
|
||||||
|
showToast('Сообщение скопированно', { timeoutMs: 1000 });
|
||||||
|
} catch {
|
||||||
|
showToast('Не удалось скопировать сообщение', { kind: 'error', timeoutMs: 1200 });
|
||||||
|
} finally {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
|
||||||
|
close();
|
||||||
|
if (typeof onReadAloud === 'function') await onReadAloud();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTtsMissingConfigDialog(navigate) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="chat-tts-missing-modal">
|
||||||
|
<div class="modal-card stack dm-dialog-card">
|
||||||
|
<h3 class="modal-title">Озвучка не настроена</h3>
|
||||||
|
<p class="meta-muted">Перейти в настройки инструментов?</p>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" type="button" id="chat-tts-no">Нет</button>
|
||||||
|
<button class="primary-btn" type="button" id="chat-tts-yes">Да</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const close = () => { root.innerHTML = ''; };
|
||||||
|
root.querySelector('#chat-tts-no')?.addEventListener('click', close);
|
||||||
|
root.querySelector('#chat-tts-yes')?.addEventListener('click', () => {
|
||||||
|
close();
|
||||||
|
navigate('tools-settings-view');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResizeComposer(textarea) {
|
||||||
|
if (!textarea) return;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirmContactModal(targetLogin = '') {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
if (!root) return Promise.resolve(false);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="contact-confirm-modal">
|
||||||
|
<div class="modal-card stack dm-dialog-card">
|
||||||
|
<h3 class="modal-title">Добавить собеседника</h3>
|
||||||
|
<p class="meta-muted">Добавить пользователя @${targetLogin} в контакты?</p>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" type="button" id="contact-confirm-no">Нет</button>
|
||||||
|
<button class="primary-btn" type="button" id="contact-confirm-yes">Да</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const close = (answer) => {
|
||||||
|
root.innerHTML = '';
|
||||||
|
resolve(!!answer);
|
||||||
|
};
|
||||||
|
root.querySelector('#contact-confirm-no')?.addEventListener('click', () => close(false));
|
||||||
|
root.querySelector('#contact-confirm-yes')?.addEventListener('click', () => close(true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseBaseKey(baseKey) {
|
function parseBaseKey(baseKey) {
|
||||||
const raw = String(baseKey || '').trim();
|
const raw = String(baseKey || '').trim();
|
||||||
const parts = raw.split('|');
|
const parts = raw.split('|');
|
||||||
@ -82,7 +176,7 @@ function scrollToLatestMessage(list) {
|
|||||||
window.setTimeout(apply, 120);
|
window.setTimeout(apply, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLog(list, chatId) {
|
function renderLog(list, chatId, { onOpenActions } = {}) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const messages = getChatMessages(chatId);
|
const messages = getChatMessages(chatId);
|
||||||
let unreadSeparatorInserted = false;
|
let unreadSeparatorInserted = false;
|
||||||
@ -122,6 +216,9 @@ function renderLog(list, chatId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bubble.append(textNode, metaNode);
|
bubble.append(textNode, metaNode);
|
||||||
|
bubble.addEventListener('click', () => {
|
||||||
|
if (typeof onOpenActions === 'function') onOpenActions(msg);
|
||||||
|
});
|
||||||
list.append(bubble);
|
list.append(bubble);
|
||||||
});
|
});
|
||||||
scrollToLatestMessage(list);
|
scrollToLatestMessage(list);
|
||||||
@ -142,20 +239,42 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: `Чат: ${contact.name}`,
|
title: `Чат с ${contact.name}`,
|
||||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||||
rightActions: [{
|
rightActions: [{
|
||||||
label: 'Позвонить',
|
label: 'Позвонить',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
try {
|
try {
|
||||||
await startOutgoingCall(chatId);
|
await startOutgoingCall(chatId);
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId, {
|
||||||
|
onOpenActions: (msg) => openMessageActionsModal({
|
||||||
|
messageText: msg?.text || '',
|
||||||
|
onReadAloud: async () => {
|
||||||
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
|
showTtsMissingConfigDialog(navigate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
} 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);
|
renderLog(log, chatId, {
|
||||||
|
onOpenActions: (msg) => openMessageActionsModal({
|
||||||
|
messageText: msg?.text || '',
|
||||||
|
onReadAloud: async () => {
|
||||||
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
|
showTtsMissingConfigDialog(navigate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
@ -168,18 +287,26 @@ export function render({ navigate, route }) {
|
|||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'secondary-btn';
|
btn.className = 'secondary-btn';
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.textContent = 'Добавить в контакты';
|
btn.textContent = 'Добавить собеседника в контакты';
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await authService.addCloseFriend(chatId);
|
const approved = await openConfirmContactModal(chatId);
|
||||||
state.contacts = [...new Set([...(state.contacts || []), chatId])];
|
if (!approved) return;
|
||||||
|
await authService.setUserRelation({
|
||||||
|
login: state.session.login,
|
||||||
|
toLogin: chatId,
|
||||||
|
kind: 'contact',
|
||||||
|
enabled: true,
|
||||||
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
|
});
|
||||||
|
const contactsPayload = await authService.listContacts();
|
||||||
|
setContacts(contactsPayload?.contacts || []);
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'contacts',
|
source: 'contacts',
|
||||||
message: `Пользователь ${chatId} добавлен в контакты`,
|
message: `Пользователь ${chatId} добавлен в контакты`,
|
||||||
});
|
});
|
||||||
btn.disabled = true;
|
card.remove();
|
||||||
btn.textContent = 'Добавлено';
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
@ -202,51 +329,29 @@ export function render({ navigate, route }) {
|
|||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.className = 'chat-input dm-chat-input';
|
form.className = 'chat-input dm-chat-input';
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
|
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="2000"></textarea>
|
||||||
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input">🎤</button>
|
<div class="dm-actions-col">
|
||||||
<button class="ghost-btn dm-voice-btn" type="button" id="chat-read-aloud">🔊</button>
|
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
|
||||||
<button class="primary-btn dm-send-btn" type="submit">Отправить</button>
|
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
|
const sendTextMessage = async (rawText) => {
|
||||||
const input = form.elements.message;
|
const text = String(rawText || '').trim();
|
||||||
await openSpeechInputModal({
|
|
||||||
navigate,
|
|
||||||
onTextReady: (text) => {
|
|
||||||
const prev = String(input.value || '').trim();
|
|
||||||
input.value = prev ? `${prev} ${text}` : text;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
form.querySelector('#chat-read-aloud')?.addEventListener('click', async () => {
|
|
||||||
const input = form.elements.message;
|
|
||||||
const text = String(input.value || '').trim();
|
|
||||||
if (!text) {
|
|
||||||
window.alert('Введите текст для озвучки.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
|
||||||
const goSettings = window.confirm('Озвучка не настроена. Перейти в настройки инструментов?');
|
|
||||||
if (goSettings) navigate('tools-settings-view');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await speakTextBySettings(text, state.entrySettings);
|
|
||||||
} catch (error) {
|
|
||||||
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const input = form.elements.message;
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
const tempId = addOutgoingPendingMessage(chatId, text);
|
const tempId = addOutgoingPendingMessage(chatId, text);
|
||||||
input.value = '';
|
renderLog(log, chatId, {
|
||||||
renderLog(log, chatId);
|
onOpenActions: (msg) => openMessageActionsModal({
|
||||||
|
messageText: msg?.text || '',
|
||||||
|
onReadAloud: async () => {
|
||||||
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
|
showTtsMissingConfigDialog(navigate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authService.sendDirectMessage({
|
const result = await authService.sendDirectMessage({
|
||||||
@ -259,7 +364,18 @@ export function render({ navigate, route }) {
|
|||||||
messageKey: result?.outgoingKey || '',
|
messageKey: result?.outgoingKey || '',
|
||||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||||
});
|
});
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId, {
|
||||||
|
onOpenActions: (msg) => openMessageActionsModal({
|
||||||
|
messageText: msg?.text || '',
|
||||||
|
onReadAloud: async () => {
|
||||||
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
|
showTtsMissingConfigDialog(navigate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'outgoing-dm',
|
source: 'outgoing-dm',
|
||||||
@ -282,13 +398,63 @@ export function render({ navigate, route }) {
|
|||||||
error: e?.message || 'unknown',
|
error: e?.message || 'unknown',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId, {
|
||||||
|
onOpenActions: (msg) => openMessageActionsModal({
|
||||||
|
messageText: msg?.text || '',
|
||||||
|
onReadAloud: async () => {
|
||||||
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
|
showTtsMissingConfigDialog(navigate);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = form.elements.message;
|
||||||
|
autoResizeComposer(input);
|
||||||
|
input?.addEventListener('input', () => autoResizeComposer(input));
|
||||||
|
|
||||||
|
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
|
||||||
|
await openSpeechInputModal({
|
||||||
|
navigate,
|
||||||
|
onTextReady: (text) => {
|
||||||
|
const prev = String(input.value || '').trim();
|
||||||
|
input.value = prev ? `${prev} ${text}` : text;
|
||||||
|
autoResizeComposer(input);
|
||||||
|
},
|
||||||
|
onSendText: async (text) => sendTextMessage(text),
|
||||||
|
onSendQueued: () => {
|
||||||
|
showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
input.value = '';
|
||||||
|
autoResizeComposer(input);
|
||||||
|
await sendTextMessage(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.append(log, form);
|
wrap.append(log, form);
|
||||||
screen.append(wrap);
|
screen.append(wrap);
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId, {
|
||||||
|
onOpenActions: (msg) => openMessageActionsModal({
|
||||||
|
messageText: msg?.text || '',
|
||||||
|
onReadAloud: async () => {
|
||||||
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
|
showTtsMissingConfigDialog(navigate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
void sendReadReceiptsForVisible(chatId);
|
void sendReadReceiptsForVisible(chatId);
|
||||||
return screen;
|
return screen;
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export function render({ navigate }) {
|
|||||||
const topActions = document.createElement('div');
|
const topActions = document.createElement('div');
|
||||||
topActions.className = 'profile-top-actions';
|
topActions.className = 'profile-top-actions';
|
||||||
topActions.innerHTML = `
|
topActions.innerHTML = `
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Изменить профиль</button>
|
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Редактировать профиль</button>
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="wallet">Кошелёк</button>
|
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="wallet">Кошелёк</button>
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
|
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
|
||||||
`;
|
`;
|
||||||
@ -61,13 +61,6 @@ export function render({ navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const statusRow = document.createElement('div');
|
|
||||||
statusRow.className = 'row profile-status-row';
|
|
||||||
statusRow.innerHTML = `
|
|
||||||
<div class="status-line" data-profile-status-line="true">Загрузка параметров...</div>
|
|
||||||
<button class="ghost-btn profile-refresh-btn" type="button" data-reload="true">Обновить</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const badgesRow = document.createElement('div');
|
const badgesRow = document.createElement('div');
|
||||||
badgesRow.className = 'row';
|
badgesRow.className = 'row';
|
||||||
badgesRow.innerHTML = `
|
badgesRow.innerHTML = `
|
||||||
@ -78,8 +71,6 @@ export function render({ navigate }) {
|
|||||||
const listWrap = document.createElement('div');
|
const listWrap = document.createElement('div');
|
||||||
listWrap.className = 'stack profile-param-list';
|
listWrap.className = 'stack profile-param-list';
|
||||||
|
|
||||||
const reloadBtn = statusRow.querySelector('[data-reload="true"]');
|
|
||||||
const statusLineEl = statusRow.querySelector('[data-profile-status-line="true"]');
|
|
||||||
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
||||||
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
||||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
||||||
@ -155,10 +146,6 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
async function refreshProfileSnapshot() {
|
async function refreshProfileSnapshot() {
|
||||||
try {
|
try {
|
||||||
if (statusLineEl instanceof HTMLElement) {
|
|
||||||
statusLineEl.className = 'status-line';
|
|
||||||
statusLineEl.textContent = 'Загрузка параметров...';
|
|
||||||
}
|
|
||||||
const snapshot = await loadProfileSnapshot(login);
|
const snapshot = await loadProfileSnapshot(login);
|
||||||
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
||||||
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
||||||
@ -168,39 +155,12 @@ export function render({ navigate }) {
|
|||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
updateTogglesUi();
|
updateTogglesUi();
|
||||||
renderFields(currentFields);
|
renderFields(currentFields);
|
||||||
if (statusLineEl instanceof HTMLElement) {
|
|
||||||
statusLineEl.className = 'status-line is-available';
|
|
||||||
statusLineEl.textContent = 'Профиль обновлён.';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (statusLineEl instanceof HTMLElement) {
|
// ignore status row in profile-view
|
||||||
statusLineEl.className = 'status-line is-unavailable';
|
|
||||||
statusLineEl.textContent = `Ошибка загрузки профиля: ${error?.message || 'unknown'}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showToggleInfo = (toggleKey) => {
|
card.append(topRow, badgesRow, listWrap);
|
||||||
const item = currentToggles.find((entry) => entry.key === toggleKey);
|
|
||||||
const isEnabled = Boolean(item?.enabled);
|
|
||||||
if (toggleKey === 'official') {
|
|
||||||
if (statusLineEl instanceof HTMLElement) statusLineEl.className = 'status-line is-available';
|
|
||||||
if (statusLineEl instanceof HTMLElement) statusLineEl.textContent = isEnabled
|
|
||||||
? 'Аккаунт является официальным.'
|
|
||||||
: 'Аккаунт не является официальным.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (statusLineEl instanceof HTMLElement) statusLineEl.className = 'status-line is-available';
|
|
||||||
if (statusLineEl instanceof HTMLElement) statusLineEl.textContent = isEnabled
|
|
||||||
? 'Аккаунт является сияющим.'
|
|
||||||
: 'Аккаунт не является сияющим.';
|
|
||||||
};
|
|
||||||
|
|
||||||
reloadBtn?.addEventListener('click', refreshProfileSnapshot);
|
|
||||||
officialBtn?.addEventListener('click', () => showToggleInfo('official'));
|
|
||||||
shineBtn?.addEventListener('click', () => showToggleInfo('shine'));
|
|
||||||
|
|
||||||
card.append(topRow, badgesRow, listWrap, statusRow);
|
|
||||||
screen.append(card);
|
screen.append(card);
|
||||||
|
|
||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
|
|||||||
@ -2353,7 +2353,7 @@ textarea.input {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -66%);
|
transform: translate(-50%, -66%) !important;
|
||||||
max-width: 72vw;
|
max-width: 72vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@ -2366,7 +2366,12 @@ textarea.input {
|
|||||||
|
|
||||||
.channels-screen .page-header .channel-header-route-btn:hover,
|
.channels-screen .page-header .channel-header-route-btn:hover,
|
||||||
.channels-screen .page-header .channel-header-route-btn:focus-visible {
|
.channels-screen .page-header .channel-header-route-btn:focus-visible {
|
||||||
transform: translate(-50%, -66%);
|
transform: translate(-50%, -66%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-screen .page-header .channel-header-route-btn:active,
|
||||||
|
.channels-screen .page-header .channel-header-route-btn.is-springing {
|
||||||
|
transform: translate(-50%, -66%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-node-heading {
|
.thread-node-heading {
|
||||||
@ -3458,7 +3463,8 @@ textarea.input {
|
|||||||
|
|
||||||
.dm-chat-input {
|
.dm-chat-input {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: 1fr auto auto auto;
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-voice-btn {
|
.dm-voice-btn {
|
||||||
@ -3466,6 +3472,21 @@ textarea.input {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dm-actions-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-chat-input .dm-input {
|
||||||
|
min-height: 42px;
|
||||||
|
max-height: 180px;
|
||||||
|
resize: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.voice-level-wrap {
|
.voice-level-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@ -3503,6 +3524,35 @@ textarea.input {
|
|||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dm-send-icon-btn {
|
||||||
|
min-width: 42px;
|
||||||
|
width: 42px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-message-actions-menu {
|
||||||
|
width: min(52vw, 240px);
|
||||||
|
padding: 8px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-message-action-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-actions-top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-send-now-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* DM messages-list status + empty block as full glass buttons */
|
/* DM messages-list status + empty block as full glass buttons */
|
||||||
.dm-screen .dm-status-line {
|
.dm-screen .dm-status-line {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user