Compare commits

..
22 changed files with 265 additions and 626 deletions
+2 -7
View File
@@ -79,12 +79,8 @@
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ. - Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
## Версионирование ## Версионирование
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория). - Все правила по коммитам, merge в `main`, `git push` и обновлению `VERSION.properties` находятся в `COMMIT_AND_VERSION_RULES.md`.
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`: - Этот файл считать единым источником истины по правилам версионирования и коммитов для данного репозитория.
- `client.version` — версия клиентского UI.
- `server.version` — версия серверной части.
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
- Обычные коммиты делать стандартным `git commit`; переменная `$GITEA_TOKEN` для коммитов не нужна и не используется.
## Deploy ## Deploy
- Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`. - Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`.
@@ -93,7 +89,6 @@
- Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`. - Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`.
- В deploy-документах и скриптах использовать домены, а не IP. - В deploy-документах и скриптах использовать домены, а не IP.
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке. - По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
- Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя. - Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя.
- Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`. - Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`.
- Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически. - Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически.
+47
View File
@@ -0,0 +1,47 @@
# Правила коммитов, merge и версионирования
Этот файл является единым источником правил для:
- коммитов;
- merge в `main`;
- изменения версий в `VERSION.properties`;
- `git push` из этого репозитория.
## Язык
- Пояснения к коммитам, PR и merge-запросам писать на русском языке.
## Где хранится версия
- Единый файл версий проекта: `VERSION.properties` в корне репозитория.
- Основные поля:
- `client.version` — версия клиентского UI.
- `server.version` — версия серверной части.
## Базовое правило для обычных коммитов
- Перед каждым новым коммитом обязательно обновлять версии в `VERSION.properties`.
- Если менялся только UI, увеличивать только `client.version`.
- Если менялся только сервер, увеличивать только `server.version`.
- Если менялись и UI, и сервер, увеличивать обе версии.
- Для обычных коммитов вне `main` использовать стандартный patch-инкремент: `+1` к последнему числовому сегменту.
- Пример: `1.2.346``1.2.347`.
## Правило для `main`
- Ветка `main` предназначена только для стабильных версий.
- По умолчанию в `main` нужно не коммитить напрямую, а мержить готовые изменения из рабочей ветки.
- Если пользователь просит сделать прямой коммит в `main`, нужно отдельно и явно предупредить, что это обход обычного стабильного процесса, и обязательно переспросить подтверждение.
## Версионирование при merge или прямом коммите в `main`
- Для попадания изменений в `main` действует отдельная схема инкремента.
- Нужно увеличивать вторую цифру версии и обнулять третью.
- Пример: `1.2.346``1.3.0`.
- Если в наборе изменений менялся только UI, обновлять только `client.version`.
- Если в наборе изменений менялся только сервер, обновлять только `server.version`.
- Если соответствующая часть не менялась, её версию не трогать.
## Правила для git commit и git push
- Обычные коммиты делать стандартным `git commit`; токен для локального коммита не нужен и не используется.
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.2.342 client.version=1.3.0
server.version=1.2.314 server.version=1.3.0
+1 -1
View File
@@ -8,7 +8,7 @@ EXPECTED_CADDY_SITE="server2.shineup.me" \
DEPLOY_SERVER_LOGIN="server2shineupme" \ DEPLOY_SERVER_LOGIN="server2shineupme" \
DEPLOY_SERVER_ADDRESS="server2.shineup.me" \ DEPLOY_SERVER_ADDRESS="server2.shineup.me" \
DEPLOY_SOLANA_CLUSTER="mainnet" \ DEPLOY_SOLANA_CLUSTER="mainnet" \
DEPLOY_SOLANA_ENDPOINT="https://solana-rpc.publicnode.com" \ DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
TARGET_URL="https://server2.shineup.me" \ TARGET_URL="https://server2.shineup.me" \
REQUIRE_BACKUP="1" \ REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh" bash "$(dirname "$0")/deploy_ui.sh"
+1 -1
View File
@@ -8,7 +8,7 @@ EXPECTED_CADDY_SITE="shineup.me" \
DEPLOY_SERVER_LOGIN="shineupme" \ DEPLOY_SERVER_LOGIN="shineupme" \
DEPLOY_SERVER_ADDRESS="shineup.me" \ DEPLOY_SERVER_ADDRESS="shineup.me" \
DEPLOY_SOLANA_CLUSTER="mainnet" \ DEPLOY_SOLANA_CLUSTER="mainnet" \
DEPLOY_SOLANA_ENDPOINT="https://solana-rpc.publicnode.com" \ DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
TARGET_URL="https://shineup.me" \ TARGET_URL="https://shineup.me" \
REQUIRE_BACKUP="1" \ REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh" bash "$(dirname "$0")/deploy_ui.sh"
+2 -3
View File
@@ -1,5 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="52" height="52" rx="16" fill="#121A24" stroke="#E7EDF7" stroke-opacity="0.18" stroke-width="2"/> <circle cx="32" cy="24.5" r="10.5" stroke="#F2F5FB" stroke-width="3"/>
<circle cx="32" cy="26" r="8.5" stroke="#F2F5FB" stroke-width="2.6"/> <path d="M15.5 48C17.8 38.9 24.2 34.5 32 34.5C39.8 34.5 46.2 38.9 48.5 48" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round"/>
<path d="M19 45.5C20.9 37.8 26.1 34 32 34C37.9 34 43.1 37.8 45 45.5" stroke="#F2F5FB" stroke-width="2.6" stroke-linecap="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 312 B

@@ -1,5 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="52" height="52" rx="16" fill="#121A24" stroke="#E7EDF7" stroke-opacity="0.18" stroke-width="2"/>
<circle cx="32" cy="32" r="10.2" stroke="#F2F5FB" stroke-width="2.8"/> <circle cx="32" cy="32" r="10.2" stroke="#F2F5FB" stroke-width="2.8"/>
<path d="M32 16.8L34.8 20.4L39.3 19.2L40.2 23.8L44.8 24.7L43.6 29.2L47.2 32L43.6 34.8L44.8 39.3L40.2 40.2L39.3 44.8L34.8 43.6L32 47.2L29.2 43.6L24.7 44.8L23.8 40.2L19.2 39.3L20.4 34.8L16.8 32L20.4 29.2L19.2 24.7L23.8 23.8L24.7 19.2L29.2 20.4L32 16.8Z" stroke="#F2F5FB" stroke-width="2.4" stroke-linejoin="round"/> <path d="M32 16.8L34.8 20.4L39.3 19.2L40.2 23.8L44.8 24.7L43.6 29.2L47.2 32L43.6 34.8L44.8 39.3L40.2 40.2L39.3 44.8L34.8 43.6L32 47.2L29.2 43.6L24.7 44.8L23.8 40.2L19.2 39.3L20.4 34.8L16.8 32L20.4 29.2L19.2 24.7L23.8 23.8L24.7 19.2L29.2 20.4L32 16.8Z" stroke="#F2F5FB" stroke-width="2.4" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="3.5" fill="#F2F5FB"/> <circle cx="32" cy="32" r="3.5" fill="#F2F5FB"/>

Before

Width:  |  Height:  |  Size: 667 B

After

Width:  |  Height:  |  Size: 543 B

+4 -4
View File
@@ -1,6 +1,6 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="52" height="52" rx="16" fill="#121A24" stroke="#E7EDF7" stroke-opacity="0.18" stroke-width="2"/> <path d="M13.5 24.5C13.5 20.9101 16.4101 18 20 18H41.5C45.0899 18 48 20.9101 48 24.5V26H23.5C21.0147 26 19 28.0147 19 30.5C19 32.9853 21.0147 35 23.5 35H48V39.5C48 43.0899 45.0899 46 41.5 46H20C16.4101 46 13.5 43.0899 13.5 39.5V24.5Z" stroke="#F2F5FB" stroke-width="3" stroke-linejoin="round"/>
<path d="M18 25.5C18 22.4624 20.4624 20 23.5 20H40.5C43.5376 20 46 22.4624 46 25.5V38.5C46 41.5376 43.5376 44 40.5 44H23.5C20.4624 44 18 41.5376 18 38.5V25.5Z" stroke="#F2F5FB" stroke-width="2.6"/> <path d="M19 30.5C19 28.0147 21.0147 26 23.5 26H50.5V35H23.5C21.0147 35 19 32.9853 19 30.5Z" stroke="#F2F5FB" stroke-width="3" stroke-linejoin="round"/>
<path d="M38 31.5H48V39C48 41.2091 46.2091 43 44 43H38V31.5Z" fill="#121A24" stroke="#F2F5FB" stroke-width="2.6"/> <circle cx="42.5" cy="30.5" r="2.4" fill="#F2F5FB"/>
<circle cx="42.5" cy="37.2" r="1.8" fill="#F2F5FB"/> <path d="M18.5 22H38" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round" opacity="0.9"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 599 B

After

Width:  |  Height:  |  Size: 707 B

-2
View File
@@ -58,7 +58,6 @@ import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as settingsView from './pages/settings-view.js'; import * as settingsView from './pages/settings-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as toolsSettingsView from './pages/tools-settings-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435'; import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
@@ -119,7 +118,6 @@ const routes = {
'settings-view': settingsView, 'settings-view': settingsView,
'developer-settings-view': developerSettingsView, 'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView, 'server-settings-view': serverSettingsView,
'tools-settings-view': toolsSettingsView,
'remote-addblock-session-view': remoteAddBlockSessionView, 'remote-addblock-session-view': remoteAddBlockSessionView,
'device-view': deviceView, 'device-view': deviceView,
'connect-device-view': connectDeviceView, 'connect-device-view': connectDeviceView,
+7 -1
View File
@@ -6,6 +6,7 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
left.className = 'header-left'; left.className = 'header-left';
if (leftAction) { if (leftAction) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'icon-btn'; btn.className = 'icon-btn';
btn.textContent = leftAction.label; btn.textContent = leftAction.label;
btn.addEventListener('click', leftAction.onClick); btn.addEventListener('click', leftAction.onClick);
@@ -26,10 +27,15 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
right.className = 'header-actions'; right.className = 'header-actions';
rightActions.forEach((action) => { rightActions.forEach((action) => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button';
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`; btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
btn.textContent = action.label;
if (action.title) btn.title = action.title; if (action.title) btn.title = action.title;
if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel); if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel);
if (action.iconNode instanceof Node) {
btn.append(action.iconNode);
} else {
btn.textContent = action.label;
}
btn.addEventListener('click', action.onClick); btn.addEventListener('click', action.onClick);
right.append(btn); right.append(btn);
}); });
@@ -1,132 +0,0 @@
import {
createMicrophoneRecorder,
isSpeechToTextConfigured,
transcribeAudioBySettings,
} from '../services/speech-tools-service.js';
import { state } from '../state.js';
function formatDuration(ms) {
const totalSec = Math.max(0, Math.floor(Number(ms || 0) / 1000));
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
const ss = String(totalSec % 60).padStart(2, '0');
return `${mm}:${ss}`;
}
function showSttMissingConfigDialog(navigate) {
const goSettings = window.confirm(
'Распознавание речи не настроено. Перейти в настройки инструментов?'
);
if (goSettings) navigate('tools-settings-view');
}
export async function openSpeechInputModal({ navigate, onTextReady, onSendText, onSendQueued }) {
if (!isSpeechToTextConfigured(state.entrySettings)) {
showSttMissingConfigDialog(navigate);
return;
}
const root = document.getElementById('modal-root');
const host = document.createElement('div');
host.innerHTML = `
<div class="modal" id="speech-input-modal-layer">
<div class="modal-card stack">
<h3 class="modal-title">Голосовой ввод</h3>
<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>
<p class="meta-muted" id="speech-input-time">00:00</p>
<p class="inline-error" id="speech-input-error"></p>
<div class="speech-actions-top">
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
<button class="primary-btn" type="button" id="speech-ok">OK</button>
</div>
<button class="primary-btn speech-send-now-btn" type="button" id="speech-send-now">Распознать и сразу отправить сообщение</button>
</div>
</div>
`;
root.append(host);
const statusEl = host.querySelector('#speech-input-status');
const timeEl = host.querySelector('#speech-input-time');
const levelEl = host.querySelector('#speech-level-fill');
const errorEl = host.querySelector('#speech-input-error');
const cancelBtn = host.querySelector('#speech-cancel');
const sendNowBtn = host.querySelector('#speech-send-now');
const okBtn = host.querySelector('#speech-ok');
const recorder = createMicrophoneRecorder();
let closed = false;
let busy = false;
const close = () => {
if (closed) return;
closed = true;
host.remove();
};
const setBusy = (flag) => {
busy = !!flag;
cancelBtn.disabled = busy;
sendNowBtn.disabled = busy;
okBtn.disabled = busy;
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
sendNowBtn.textContent = busy ? 'Распознаю...' : 'Распознать и сразу отправить сообщение';
};
try {
await recorder.start(({ elapsedMs, level }) => {
if (timeEl) timeEl.textContent = formatDuration(elapsedMs);
if (levelEl) levelEl.style.width = `${Math.max(2, Math.round((Number(level) || 0) * 100))}%`;
});
} catch (error) {
close();
window.alert(`Не удалось получить доступ к микрофону: ${error?.message || 'unknown'}`);
return;
}
cancelBtn.addEventListener('click', () => {
recorder.cancel();
close();
});
okBtn.addEventListener('click', async () => {
if (busy) return;
setBusy(true);
try {
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);
if (typeof onTextReady === 'function') onTextReady(text);
close();
} catch (error) {
setBusy(false);
statusEl.textContent = 'Идёт запись...';
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'}`);
}
});
}
+1 -1
View File
@@ -6,4 +6,4 @@ export const defaultServerAddress = 'shineup.me';
export const defaultServerHttp = `https://${defaultServerAddress}`; export const defaultServerHttp = `https://${defaultServerAddress}`;
export const defaultServerWs = `wss://${defaultServerAddress}/ws`; export const defaultServerWs = `wss://${defaultServerAddress}/ws`;
export const defaultSolanaCluster = 'mainnet-beta'; export const defaultSolanaCluster = 'mainnet-beta';
export const defaultSolanaEndpoint = 'https://solana-rpc.publicnode.com'; export const defaultSolanaEndpoint = 'https://public.rpc.solanavibestation.com/';
-26
View File
@@ -10,7 +10,6 @@ import {
showToast, showToast,
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { navigateBack } from '../router.js'; import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -339,9 +338,6 @@ function openReplyModal({ onSubmit, navigate }) {
<div class="modal-card stack"> <div class="modal-card stack">
<h3 class="modal-title">Ответ</h3> <h3 class="modal-title">Ответ</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea> <textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="thread-reply-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="thread-reply-error"></div> <div class="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button> <button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
@@ -368,15 +364,6 @@ function openReplyModal({ onSubmit, navigate }) {
}; };
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close); root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
root.querySelector('#thread-reply-voice')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(textEl?.value || '').trim();
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
},
});
});
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => { root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
if (inFlight) return; if (inFlight) return;
@@ -423,9 +410,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
<select id="thread-repost-channel-select" class="input">${options}</select> <select id="thread-repost-channel-select" class="input">${options}</select>
<label class="meta-muted" for="thread-repost-comment">Комментарий</label> <label class="meta-muted" for="thread-repost-comment">Комментарий</label>
<textarea id="thread-repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea> <textarea id="thread-repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="thread-repost-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="thread-repost-error"></div> <div class="meta-muted inline-error" id="thread-repost-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="thread-repost-cancel" type="button">Отмена</button> <button class="secondary-btn" id="thread-repost-cancel" type="button">Отмена</button>
@@ -456,16 +440,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
}; };
root.querySelector('#thread-repost-cancel')?.addEventListener('click', close); root.querySelector('#thread-repost-cancel')?.addEventListener('click', close);
root.querySelector('#thread-repost-voice')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(textEl?.value || '').trim();
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
},
});
});
submitEl?.addEventListener('click', async () => { submitEl?.addEventListener('click', async () => {
if (inFlight) return; if (inFlight) return;
const idx = Number(selectEl?.value ?? -1); const idx = Number(selectEl?.value ?? -1);
-25
View File
@@ -16,7 +16,6 @@ import {
showToast, showToast,
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { navigateBack } from '../router.js'; import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -314,9 +313,6 @@ function openReplyModal({ onSubmit, navigate }) {
<div class="modal-card stack"> <div class="modal-card stack">
<h3 class="modal-title">Ответ</h3> <h3 class="modal-title">Ответ</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea> <textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="reply-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="reply-error"></div> <div class="meta-muted inline-error" id="reply-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button> <button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
@@ -343,15 +339,6 @@ function openReplyModal({ onSubmit, navigate }) {
}; };
root.querySelector('#reply-cancel')?.addEventListener('click', close); root.querySelector('#reply-cancel')?.addEventListener('click', close);
root.querySelector('#reply-voice')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(textEl?.value || '').trim();
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
},
});
});
submitEl?.addEventListener('click', async () => { submitEl?.addEventListener('click', async () => {
if (inFlight) return; if (inFlight) return;
@@ -399,9 +386,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
<select id="repost-channel-select" class="input">${options}</select> <select id="repost-channel-select" class="input">${options}</select>
<label class="meta-muted" for="repost-comment">Комментарий</label> <label class="meta-muted" for="repost-comment">Комментарий</label>
<textarea id="repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea> <textarea id="repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
<div class="row wrap-row">
<button class="ghost-btn" id="repost-voice" type="button">🎤 Голосом</button>
</div>
<div class="meta-muted inline-error" id="repost-error"></div> <div class="meta-muted inline-error" id="repost-error"></div>
<div class="form-actions-grid"> <div class="form-actions-grid">
<button class="secondary-btn" id="repost-cancel" type="button">Отмена</button> <button class="secondary-btn" id="repost-cancel" type="button">Отмена</button>
@@ -429,15 +413,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
const close = () => { root.innerHTML = ''; }; const close = () => { root.innerHTML = ''; };
root.querySelector('#repost-cancel')?.addEventListener('click', close); root.querySelector('#repost-cancel')?.addEventListener('click', close);
root.querySelector('#repost-voice')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(textEl?.value || '').trim();
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
},
});
});
submitEl?.addEventListener('click', async () => { submitEl?.addEventListener('click', async () => {
if (inFlight) return; if (inFlight) return;
const idx = Number(selectEl?.value ?? -1); const idx = Number(selectEl?.value ?? -1);
+55 -40
View File
@@ -24,8 +24,7 @@ import {
hasTwemojiAsset, hasTwemojiAsset,
stopAllTwemojiAnimations, stopAllTwemojiAnimations,
} from '../components/emoji-picker.js?v=202607152130'; } from '../components/emoji-picker.js?v=202607152130';
import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js'; import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
@@ -314,27 +313,22 @@ function openChatActionsMenu({
}); });
} }
function showTtsMissingConfigDialog(navigate) { function showTtsMissingConfigDialog() {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
if (!root) return; if (!root) return;
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="chat-tts-missing-modal"> <div class="modal" id="chat-tts-missing-modal">
<div class="modal-card stack dm-dialog-card"> <div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Озвучка не настроена</h3> <h3 class="modal-title">Озвучка не настроена</h3>
<p class="meta-muted">Перейти в настройки инструментов?</p> <p class="meta-muted">Функция голосовых инструментов сейчас отключена.</p>
<div class="form-actions-grid"> <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-ok">OK</button>
<button class="primary-btn" type="button" id="chat-tts-yes">Да</button>
</div> </div>
</div> </div>
</div> </div>
`; `;
const close = () => { root.innerHTML = ''; }; const close = () => { root.innerHTML = ''; };
root.querySelector('#chat-tts-no')?.addEventListener('click', close); root.querySelector('#chat-tts-ok')?.addEventListener('click', close);
root.querySelector('#chat-tts-yes')?.addEventListener('click', () => {
close();
navigate('tools-settings-view');
});
} }
function autoResizeComposer(textarea) { function autoResizeComposer(textarea) {
@@ -343,6 +337,20 @@ function autoResizeComposer(textarea) {
textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`; textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`;
} }
function createHeaderPhoneHandsetIcon() {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', 'header-icon-svg header-icon-svg--phone');
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', 'M7.62 10.79a15.1 15.1 0 0 0 5.59 5.59l1.87-1.87a1 1 0 0 1 1.02-.24c1.12.37 2.31.57 3.52.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.61 21 3 13.39 3 4a1 1 0 0 1 1-1h3.37a1 1 0 0 1 1 1c0 1.21.2 2.4.57 3.52a1 1 0 0 1-.24 1.02l-1.08 1.27Z');
path.setAttribute('fill', 'currentColor');
svg.append(path);
return svg;
}
function openConfirmContactModal(targetLogin = '') { function openConfirmContactModal(targetLogin = '') {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false); if (!root) return Promise.resolve(false);
@@ -762,7 +770,6 @@ export function render({ navigate, route }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-chat-screen'; screen.className = 'stack dm-screen dm-chat-screen';
const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings);
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings); const 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);
@@ -775,7 +782,7 @@ export function render({ navigate, route }) {
const handleReadAloud = async (msg) => { const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) { if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate); showTtsMissingConfigDialog();
return; return;
} }
const parsedText = parseDmTechBlocks(String(msg?.text || '')); const parsedText = parseDmTechBlocks(String(msg?.text || ''));
@@ -825,6 +832,16 @@ export function render({ navigate, route }) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap'; wrap.className = 'chat-wrap dm-chat-wrap';
const historyLoader = document.createElement('div');
historyLoader.className = 'dm-history-loader';
historyLoader.hidden = true;
historyLoader.innerHTML = `
<div class="dm-history-loader__pill" aria-live="polite" aria-busy="true">
<span class="dm-history-loader__spinner" aria-hidden="true"></span>
<span class="dm-history-loader__label">Загрузка истории</span>
</div>
`;
const log = document.createElement('div'); const log = document.createElement('div');
log.className = 'messages-log dm-messages-log'; log.className = 'messages-log dm-messages-log';
@@ -834,10 +851,10 @@ export function render({ navigate, route }) {
leftAction: { label: '←', onClick: () => navigate('messages-list') }, leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [ rightActions: [
{ {
label: '☎',
title: 'Позвонить', title: 'Позвонить',
ariaLabel: 'Позвонить', ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn', className: 'chat-header-icon-btn chat-header-call-btn',
iconNode: createHeaderPhoneHandsetIcon(),
onClick: handleStartCall, onClick: handleStartCall,
}, },
{ {
@@ -948,8 +965,7 @@ export function render({ navigate, route }) {
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div> <div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea> <textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<div class="dm-actions-col"> <div class="dm-actions-col">
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"></button> <button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph"></span></button>
${isSpeechToTextReady ? '<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>' : ''}
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button> <button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button>
</div> </div>
`; `;
@@ -976,6 +992,16 @@ export function render({ navigate, route }) {
} }
}; };
const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading;
historyLoader.classList.toggle('is-visible', !!isLoading);
};
const syncComposerLayout = () => {
const height = Number.parseFloat(input?.style.height || '0');
form.classList.toggle('dm-chat-input--multiline', height > 52);
};
const rememberEmojiSelection = () => { const rememberEmojiSelection = () => {
if (!input) return; if (!input) return;
const valueLength = String(input.value || '').length; const valueLength = String(input.value || '').length;
@@ -1013,6 +1039,7 @@ export function render({ navigate, route }) {
} }
} }
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
}; };
if (emojiSlot) { if (emojiSlot) {
@@ -1053,6 +1080,7 @@ export function render({ navigate, route }) {
if (restoreDraft && input) { if (restoreDraft && input) {
input.value = draftToRestore || ''; input.value = draftToRestore || '';
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
focusInputToEnd(); focusInputToEnd();
} }
}; };
@@ -1063,6 +1091,7 @@ export function render({ navigate, route }) {
syncEditBanner(); syncEditBanner();
if (restoreDraft && input) { if (restoreDraft && input) {
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
focusInputToEnd(); focusInputToEnd();
} }
}; };
@@ -1085,6 +1114,7 @@ export function render({ navigate, route }) {
if (input) { if (input) {
input.value = String(parsed?.visibleText || ''); input.value = String(parsed?.visibleText || '');
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
focusInputToEnd(); focusInputToEnd();
} }
}; };
@@ -1246,6 +1276,7 @@ export function render({ navigate, route }) {
if (input) { if (input) {
input.value = text; input.value = text;
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
focusInputToEnd(); focusInputToEnd();
} }
addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`); addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`);
@@ -1304,6 +1335,7 @@ export function render({ navigate, route }) {
if (historyLoading) return; if (historyLoading) return;
if (historyBootstrapped && !historyHasMore) return; if (historyBootstrapped && !historyHasMore) return;
historyLoading = true; historyLoading = true;
setHistoryLoadingState(true);
const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap; const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap;
const prevScrollTop = Number(scrollContainer?.scrollTop || 0); const prevScrollTop = Number(scrollContainer?.scrollTop || 0);
const prevScrollHeight = Number(scrollContainer?.scrollHeight || 0); const prevScrollHeight = Number(scrollContainer?.scrollHeight || 0);
@@ -1338,6 +1370,7 @@ export function render({ navigate, route }) {
}); });
} finally { } finally {
historyLoading = false; historyLoading = false;
setHistoryLoadingState(false);
} }
}; };
@@ -1358,9 +1391,11 @@ export function render({ navigate, route }) {
}); });
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
input?.addEventListener('input', () => { input?.addEventListener('input', () => {
rememberEmojiSelection(); rememberEmojiSelection();
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
}); });
input?.addEventListener('select', rememberEmojiSelection); input?.addEventListener('select', rememberEmojiSelection);
input?.addEventListener('keyup', rememberEmojiSelection); input?.addEventListener('keyup', rememberEmojiSelection);
@@ -1403,26 +1438,12 @@ export function render({ navigate, route }) {
if (!text) return; if (!text) return;
input.value = ''; input.value = '';
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
closeEmojiPicker(); closeEmojiPicker();
await sendTextMessage(text); await sendTextMessage(text);
focusInputToEnd(); focusInputToEnd();
}); });
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.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault(); event.preventDefault();
try { try {
@@ -1438,6 +1459,7 @@ export function render({ navigate, route }) {
if (!text) return; if (!text) return;
input.value = ''; input.value = '';
autoResizeComposer(input); autoResizeComposer(input);
syncComposerLayout();
closeEmojiPicker(); closeEmojiPicker();
await sendTextMessage(text); await sendTextMessage(text);
focusInputToEnd(); focusInputToEnd();
@@ -1457,20 +1479,13 @@ export function render({ navigate, route }) {
window.visualViewport?.addEventListener('resize', syncKeyboardUi); window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi); window.addEventListener('resize', syncKeyboardUi);
wrap.append(log, form); wrap.append(historyLoader, log, form);
screen.append(wrap); screen.append(wrap);
renderLog(log, chatId, { renderLog(log, chatId, {
onOpenActions: handleOpenActions, onOpenActions: handleOpenActions,
markAsRead: false, markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest', scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
}); });
if (hasUnreadIncoming) {
window.requestAnimationFrame(() => scrollToUnreadSeparator(log));
window.setTimeout(() => scrollToUnreadSeparator(log), 180);
} else {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 180);
}
window.setTimeout(() => { window.setTimeout(() => {
if (markChatRead(chatId) > 0) { if (markChatRead(chatId) > 0) {
notifyUnreadStateUpdated(); notifyUnreadStateUpdated();
@@ -1480,7 +1495,7 @@ export function render({ navigate, route }) {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap; boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap;
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true }); boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
void loadHistoryPage({ preserveScroll: false }); void loadHistoryPage({ preserveScroll: true });
}); });
screen.cleanup = () => { screen.cleanup = () => {
setChatKeyboardOpen(false); setChatKeyboardOpen(false);
+33 -8
View File
@@ -25,6 +25,7 @@ import { defaultServerLogin } from '../deploy-config.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
const MIN_REQUIRED_SOL = 0.01; const MIN_REQUIRED_SOL = 0.01;
const RECOMMENDED_TOPUP_SOL = 0.02;
const AUTO_LOGIN_INITIAL_DELAY_MS = 1000; const AUTO_LOGIN_INITIAL_DELAY_MS = 1000;
const AUTO_LOGIN_RETRY_MS = 2000; const AUTO_LOGIN_RETRY_MS = 2000;
const REGISTRATION_PROGRESS_DURATION_MS = 12000; const REGISTRATION_PROGRESS_DURATION_MS = 12000;
@@ -42,6 +43,20 @@ function getExplorerClusterName(endpoint) {
return 'mainnet-beta'; return 'mainnet-beta';
} }
function isTestRegistrationContour() {
const solanaCluster = getExplorerClusterName(state.entrySettings.solanaServer);
if (solanaCluster !== 'mainnet-beta') return true;
const shineServer = String(state.entrySettings.shineServer || '').trim().toLowerCase();
const shineServerLogin = String(state.entrySettings.shineServerLogin || '').trim().toLowerCase();
return (
shineServer.includes('t1.shineup.me')
|| shineServer.includes('t2.shineup.me')
|| shineServer.includes('t3.shineup.me')
|| shineServer.includes('t4.shineup.me')
|| ['t1', 't2', 't3', 't4'].includes(shineServerLogin)
);
}
function makeSolanaExplorerTxUrl(signature, endpoint) { function makeSolanaExplorerTxUrl(signature, endpoint) {
const cleanSignature = String(signature || '').trim(); const cleanSignature = String(signature || '').trim();
if (!cleanSignature) return ''; if (!cleanSignature) return '';
@@ -152,14 +167,16 @@ export function render({ navigate }) {
walletValue.type = 'text'; walletValue.type = 'text';
walletValue.value = state.registrationPayment.walletAddress || ''; walletValue.value = state.registrationPayment.walletAddress || '';
walletValue.readOnly = true; walletValue.readOnly = true;
walletValue.style.width = '100%';
const walletRow = document.createElement('div'); const walletRow = document.createElement('div');
walletRow.className = 'inline-input-row'; walletRow.className = 'stack';
const copyButton = document.createElement('button'); const copyButton = document.createElement('button');
copyButton.className = 'ghost-btn'; copyButton.className = 'ghost-btn';
copyButton.type = 'button'; copyButton.type = 'button';
copyButton.textContent = 'Скопировать номер'; copyButton.textContent = 'Скопировать номер';
copyButton.style.width = '100%';
copyButton.addEventListener('click', async () => { copyButton.addEventListener('click', async () => {
try { try {
await navigator.clipboard.writeText(walletValue.value); await navigator.clipboard.writeText(walletValue.value);
@@ -232,6 +249,7 @@ export function render({ navigate }) {
balanceRow.append(balanceValue, refreshButton); balanceRow.append(balanceValue, refreshButton);
const isTestContour = isTestRegistrationContour();
const topupButton = document.createElement('button'); const topupButton = document.createElement('button');
topupButton.className = 'ghost-btn'; topupButton.className = 'ghost-btn';
topupButton.type = 'button'; topupButton.type = 'button';
@@ -277,11 +295,15 @@ export function render({ navigate }) {
if (currentBalance == null) return; if (currentBalance == null) return;
if (currentBalance < MIN_REQUIRED_SOL) { if (currentBalance < MIN_REQUIRED_SOL) {
status.className = 'status-line is-unavailable'; status.className = 'status-line is-unavailable';
status.textContent = `Для регистрации нужно минимум ${MIN_REQUIRED_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL. Пополните на промо-странице или попросите перевод у знакомого с тестовыми SOL.`; status.textContent = isTestContour
? `Для регистрации нужно минимум ${MIN_REQUIRED_SOL} SOL. Рекомендуем пополнить кошелёк примерно на ${RECOMMENDED_TOPUP_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL.`
: `Для регистрации пополните этот кошелёк соланами примерно на ${RECOMMENDED_TOPUP_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL. Минимум для продолжения: ${MIN_REQUIRED_SOL} SOL.`;
status.style.display = ''; status.style.display = '';
const openTopup = window.confirm('Открыть страницу пополнения с вашим кошельком?'); if (isTestContour) {
if (openTopup) { const openTopup = window.confirm('Открыть страницу пополнения с вашим кошельком?');
window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer'); if (openTopup) {
window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer');
}
} }
return; return;
} }
@@ -323,15 +345,18 @@ export function render({ navigate }) {
}); });
card.innerHTML = ` card.innerHTML = `
<p class="auth-copy">Для регистрации в тестовой Solana нужно минимум 0,01 SOL на вашем кошельке.</p> <p class="auth-copy">Для регистрации пополните этот кошелёк соланами примерно на ${RECOMMENDED_TOPUP_SOL} SOL.</p>
<label class="stack"><span class="field-label">Номер кошелька (client.key)</span></label> <label class="stack"><span class="field-label">Номер кошелька</span></label>
<div class="stack"> <div class="stack">
<span class="field-label">Баланс (Solana)</span> <span class="field-label">Баланс (Solana)</span>
</div> </div>
`; `;
card.children[1].append(walletRow); card.children[1].append(walletRow);
card.children[2].append(balanceRow); card.children[2].append(balanceRow);
card.append(topupButton, showKeysButton, submitButton, status); if (isTestContour) {
card.append(topupButton);
}
card.append(showKeysButton, submitButton, status);
screen.append( screen.append(
renderHeader({ renderHeader({
+1 -8
View File
@@ -1,6 +1,5 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut, state } from '../state.js'; import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' }; export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -41,22 +40,16 @@ export function render({ navigate }) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack'; card.className = 'card stack';
card.innerHTML = ` card.innerHTML = `
<button class="text-btn" type="button" id="settings-links">Показать связи</button>
<button class="text-btn" type="button" id="settings-device">Устройства</button> <button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button> <button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button> <button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button> <button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button> <button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`; `;
card.querySelector('#settings-links').addEventListener('click', () => {
navigate(makeProfileLinksRoute(state.session.login));
});
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view')); card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view')); card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view')); card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout'); const signOutBtn = card.querySelector('#settings-signout');
-168
View File
@@ -1,168 +0,0 @@
import { renderHeader } from '../components/header.js';
import { saveEntrySettings, state } from '../state.js';
import { speakTextBySettings } from '../services/speech-tools-service.js';
export const pageMeta = { id: 'tools-settings-view', title: 'Настройки инструментов' };
function optionsMarkup(options, selected) {
return options.map((opt) => (
`<option value="${opt.value}" ${selected === opt.value ? 'selected' : ''}>${opt.label}</option>`
)).join('');
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const stt = state.entrySettings.tools?.speechToText || {};
const tts = state.entrySettings.tools?.textToSpeech || {};
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<h3 class="modal-title">Распознавание речи</h3>
<label class="field-label">Провайдер</label>
<select class="input" id="stt-provider">
${optionsMarkup([{ value: 'openai', label: 'OpenAI (Whisper/Transcribe)' }], String(stt.provider || 'openai'))}
</select>
<label class="field-label">Уровень</label>
<select class="input" id="stt-quality">
${optionsMarkup([
{ value: 'easy', label: 'Easy (дешевле)' },
{ value: 'medium', label: 'Medium' },
{ value: 'hard', label: 'Hard (лучше)' },
], String(stt.quality || 'medium'))}
</select>
<label class="field-label">Адрес OpenAI API</label>
<input class="input" id="stt-base-url" type="text" value="${String(stt.baseUrl || 'https://api.openai.com/v1')}" placeholder="https://api.openai.com/v1" />
<label class="field-label">Кастомная модель (опционально)</label>
<input class="input" id="stt-model" type="text" value="${String(stt.model || '')}" placeholder="например: gpt-4o-mini-transcribe" />
<label class="field-label">API key</label>
<input class="input" id="stt-api-key" type="password" value="${String(stt.apiKey || '')}" placeholder="sk-..." />
`;
const card2 = document.createElement('div');
card2.className = 'card stack';
card2.innerHTML = `
<h3 class="modal-title">Прочесть вслух (TTS)</h3>
<label class="field-label">Провайдер</label>
<select class="input" id="tts-provider">
${optionsMarkup([
{ value: 'browser', label: 'Браузер (SpeechSynthesis)' },
{ value: 'piper-http', label: 'Piper (локальный HTTP)' },
{ value: 'openai', label: 'OpenAI TTS API' },
], String(tts.provider || 'browser'))}
</select>
<label class="field-label">Уровень</label>
<select class="input" id="tts-quality">
${optionsMarkup([
{ value: 'easy', label: 'Easy' },
{ value: 'medium', label: 'Medium' },
{ value: 'hard', label: 'Hard' },
], String(tts.quality || 'medium'))}
</select>
<label class="field-label">Голос</label>
<input class="input" id="tts-voice" type="text" value="${String(tts.voice || '')}" placeholder="например: andrey / alloy" />
<label class="field-label">Piper HTTP адрес</label>
<input class="input" id="tts-piper-url" type="text" value="${String(tts.piperBaseUrl || 'http://127.0.0.1:5000')}" placeholder="http://127.0.0.1:5000" />
<label class="field-label">OpenAI/внешний API адрес</label>
<input class="input" id="tts-external-url" type="text" value="${String(tts.externalBaseUrl || '')}" placeholder="https://api.openai.com/v1" />
<label class="field-label">API key (для внешнего API)</label>
<input class="input" id="tts-api-key" type="password" value="${String(tts.apiKey || '')}" placeholder="sk-..." />
<label class="field-label">Модель (для внешнего API)</label>
<input class="input" id="tts-model" type="text" value="${String(tts.model || '')}" placeholder="например: gpt-4o-mini-tts" />
<label class="field-label">Тестовая фраза</label>
<input class="input" id="tts-test-text" type="text" value="Привет! Проверка озвучки работает." />
<div class="row wrap-row">
<button class="ghost-btn" type="button" id="tts-test-btn">Проверить озвучку</button>
<button class="ghost-btn" type="button" id="piper-autofill">Загрузить и настроить (шаблон)</button>
<button class="ghost-btn" type="button" id="piper-links">Ссылки на Piper/голоса</button>
</div>
<p class="meta-muted">Для офлайн-озвучки через Piper используйте локальный HTTP-обёртчик. Кнопка «шаблон» подставляет базовые значения.</p>
`;
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.innerHTML = `
<button class="ghost-btn" type="button" id="tools-cancel">Отмена</button>
<button class="primary-btn" type="button" id="tools-save">Сохранить</button>
`;
actions.querySelector('#tools-cancel')?.addEventListener('click', () => navigate('settings-view'));
actions.querySelector('#tools-save')?.addEventListener('click', async () => {
const next = {
...state.entrySettings,
tools: {
speechToText: {
provider: card.querySelector('#stt-provider')?.value || 'openai',
quality: card.querySelector('#stt-quality')?.value || 'medium',
baseUrl: String(card.querySelector('#stt-base-url')?.value || '').trim(),
apiKey: String(card.querySelector('#stt-api-key')?.value || '').trim(),
model: String(card.querySelector('#stt-model')?.value || '').trim(),
},
textToSpeech: {
provider: card2.querySelector('#tts-provider')?.value || 'browser',
quality: card2.querySelector('#tts-quality')?.value || 'medium',
voice: String(card2.querySelector('#tts-voice')?.value || '').trim(),
piperBaseUrl: String(card2.querySelector('#tts-piper-url')?.value || '').trim(),
externalBaseUrl: String(card2.querySelector('#tts-external-url')?.value || '').trim(),
apiKey: String(card2.querySelector('#tts-api-key')?.value || '').trim(),
model: String(card2.querySelector('#tts-model')?.value || '').trim(),
},
},
};
await saveEntrySettings(next);
navigate('settings-view');
});
card2.querySelector('#piper-autofill')?.addEventListener('click', () => {
card2.querySelector('#tts-provider').value = 'piper-http';
card2.querySelector('#tts-piper-url').value = 'http://127.0.0.1:5000';
card2.querySelector('#tts-quality').value = 'medium';
if (!String(card2.querySelector('#tts-voice').value || '').trim()) {
card2.querySelector('#tts-voice').value = 'ru_RU-irina-medium';
}
});
card2.querySelector('#piper-links')?.addEventListener('click', () => {
window.open('https://github.com/rhasspy/piper', '_blank', 'noopener,noreferrer');
window.open('https://huggingface.co/rhasspy/piper-voices/tree/main', '_blank', 'noopener,noreferrer');
});
card2.querySelector('#tts-test-btn')?.addEventListener('click', async () => {
const ttsProvider = card2.querySelector('#tts-provider')?.value || 'openai';
const text = String(card2.querySelector('#tts-test-text')?.value || '').trim();
const runtimeSettings = {
...state.entrySettings,
tools: {
...state.entrySettings.tools,
textToSpeech: {
provider: ttsProvider,
quality: card2.querySelector('#tts-quality')?.value || 'medium',
voice: String(card2.querySelector('#tts-voice')?.value || '').trim(),
piperBaseUrl: String(card2.querySelector('#tts-piper-url')?.value || '').trim(),
externalBaseUrl: String(card2.querySelector('#tts-external-url')?.value || '').trim(),
apiKey: String(card2.querySelector('#tts-api-key')?.value || '').trim(),
model: String(card2.querySelector('#tts-model')?.value || '').trim(),
},
},
};
try {
await speakTextBySettings(text || 'Проверка озвучки', runtimeSettings);
} catch (error) {
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
}
});
screen.append(
renderHeader({
title: 'Настройки инструментов',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
card,
card2,
actions,
);
return screen;
}
-3
View File
@@ -30,7 +30,6 @@ const PRETTY_PATHS = new Map([
['notifications-view', 'notifications'], ['notifications-view', 'notifications'],
['settings-view', 'settings'], ['settings-view', 'settings'],
['server-settings-view', 'settings/servers'], ['server-settings-view', 'settings/servers'],
['tools-settings-view', 'settings/tools'],
['developer-settings-view', 'settings/developer'], ['developer-settings-view', 'settings/developer'],
['trusted-device-login-settings-view', 'settings/device-login'], ['trusted-device-login-settings-view', 'settings/device-login'],
['language-view', 'settings/language'], ['language-view', 'settings/language'],
@@ -260,7 +259,6 @@ export function getRoute() {
if (pageId === 'settings') { if (pageId === 'settings') {
const sub = decodePart(segments[1] || '').toLowerCase(); const sub = decodePart(segments[1] || '').toLowerCase();
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} }; if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'tools') return { pageId: 'tools-settings-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} }; if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} }; if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
if (sub === 'language') return { pageId: 'language-view', params: {} }; if (sub === 'language') return { pageId: 'language-view', params: {} };
@@ -372,7 +370,6 @@ export function resolveToolbarActive(pageId) {
pageId === 'settings-view' || pageId === 'settings-view' ||
pageId === 'developer-settings-view' || pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' || pageId === 'server-settings-view' ||
pageId === 'tools-settings-view' ||
pageId === 'remote-addblock-session-view' || pageId === 'remote-addblock-session-view' ||
pageId === 'device-view' || pageId === 'device-view' ||
pageId === 'connect-device-view' || pageId === 'connect-device-view' ||
@@ -1,9 +1,3 @@
const OPENAI_MODELS_BY_QUALITY = {
easy: 'whisper-1',
medium: 'gpt-4o-mini-transcribe',
hard: 'gpt-4o-transcribe',
};
const PIPER_LENGTH_SCALE_BY_QUALITY = { const PIPER_LENGTH_SCALE_BY_QUALITY = {
easy: '1.15', easy: '1.15',
medium: '1.0', medium: '1.0',
@@ -16,18 +10,6 @@ function normalizeOpenAiBaseUrl(url) {
return value.replace(/\/+$/, ''); return value.replace(/\/+$/, '');
} }
function resolveSttModel(config) {
const quality = String(config?.quality || 'medium').toLowerCase();
const customModel = String(config?.model || '').trim();
return customModel || OPENAI_MODELS_BY_QUALITY[quality] || OPENAI_MODELS_BY_QUALITY.medium;
}
export function isSpeechToTextConfigured(entrySettings) {
const cfg = entrySettings?.tools?.speechToText || {};
if (String(cfg.provider || 'openai') !== 'openai') return false;
return !!String(cfg.apiKey || '').trim();
}
export function isTextToSpeechConfigured(entrySettings) { export function isTextToSpeechConfigured(entrySettings) {
const cfg = entrySettings?.tools?.textToSpeech || {}; const cfg = entrySettings?.tools?.textToSpeech || {};
const provider = String(cfg.provider || 'browser'); const provider = String(cfg.provider || 'browser');
@@ -37,135 +19,6 @@ export function isTextToSpeechConfigured(entrySettings) {
return false; return false;
} }
export async function transcribeAudioBySettings(audioBlob, entrySettings) {
const cfg = entrySettings?.tools?.speechToText || {};
const provider = String(cfg.provider || 'openai');
if (provider !== 'openai') {
throw new Error('Поддерживается только провайдер OpenAI для распознавания.');
}
const apiKey = String(cfg.apiKey || '').trim();
if (!apiKey) throw new Error('Не заполнен OpenAI API key.');
const model = resolveSttModel(cfg);
const baseUrl = normalizeOpenAiBaseUrl(cfg.baseUrl);
const form = new FormData();
form.append('model', model);
form.append('language', 'ru');
form.append('response_format', 'json');
form.append('file', audioBlob, 'voice-input.webm');
const response = await fetch(`${baseUrl}/audio/transcriptions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
},
body: form,
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Ошибка STT API (${response.status}): ${body || 'unknown error'}`);
}
const payload = await response.json();
const text = String(payload?.text || '').trim();
if (!text) throw new Error('Пустой ответ распознавания.');
return text;
}
export function createMicrophoneRecorder() {
const Ctx = window.AudioContext || window.webkitAudioContext;
let stream = null;
let recorder = null;
let startedAtMs = 0;
let chunks = [];
let timerId = 0;
let level = 0;
let analyser = null;
let rafId = 0;
async function start(onTick) {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
startedAtMs = Date.now();
chunks = [];
recorder.ondataavailable = (event) => {
if (event?.data?.size > 0) chunks.push(event.data);
};
recorder.start(250);
timerId = window.setInterval(() => {
if (typeof onTick === 'function') {
onTick({
elapsedMs: Date.now() - startedAtMs,
level,
});
}
}, 120);
if (Ctx) {
const audioCtx = new Ctx();
const source = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
const read = () => {
if (!analyser) return;
analyser.getByteFrequencyData(data);
let sum = 0;
for (let i = 0; i < data.length; i += 1) sum += data[i];
level = data.length > 0 ? Math.max(0, Math.min(1, (sum / data.length) / 255)) : 0;
rafId = window.requestAnimationFrame(read);
};
read();
}
}
async function stop() {
if (!recorder) return null;
const blob = await new Promise((resolve) => {
recorder.onstop = () => resolve(new Blob(chunks, { type: 'audio/webm' }));
recorder.stop();
});
cleanup();
return blob;
}
function cancel() {
try {
recorder?.stop();
} catch {
// ignore
}
cleanup();
}
function cleanup() {
if (timerId) window.clearInterval(timerId);
if (rafId) window.cancelAnimationFrame(rafId);
timerId = 0;
rafId = 0;
analyser = null;
if (stream) {
stream.getTracks().forEach((track) => {
try {
track.stop();
} catch {
// ignore
}
});
}
stream = null;
recorder = null;
}
return { start, stop, cancel };
}
export async function speakTextBySettings(text, entrySettings) { export async function speakTextBySettings(text, entrySettings) {
const value = String(text || '').trim(); const value = String(text || '').trim();
if (!value) return; if (!value) return;
-9
View File
@@ -95,7 +95,6 @@ const DEFAULT_SHINE_SERVER_LOGIN_VALUE = DEFAULT_SHINE_SERVER_LOGIN;
const DEFAULT_SHINE_SERVER_HTTP_VALUE = DEFAULT_SHINE_SERVER_HTTP; const DEFAULT_SHINE_SERVER_HTTP_VALUE = DEFAULT_SHINE_SERVER_HTTP;
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net'; const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000; const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
function normalizeStoredSolanaServer(value) { function normalizeStoredSolanaServer(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
@@ -109,16 +108,8 @@ export function normalizeDmChatId(value) {
function normalizeToolsSettings(rawTools) { function normalizeToolsSettings(rawTools) {
const source = rawTools && typeof rawTools === 'object' ? rawTools : {}; const source = rawTools && typeof rawTools === 'object' ? rawTools : {};
const stt = source.speechToText && typeof source.speechToText === 'object' ? source.speechToText : {};
const tts = source.textToSpeech && typeof source.textToSpeech === 'object' ? source.textToSpeech : {}; const tts = source.textToSpeech && typeof source.textToSpeech === 'object' ? source.textToSpeech : {};
return { return {
speechToText: {
provider: String(stt.provider || 'openai'),
baseUrl: String(stt.baseUrl || DEFAULT_OPENAI_BASE_URL),
apiKey: String(stt.apiKey || ''),
quality: String(stt.quality || 'medium'),
model: String(stt.model || ''),
},
textToSpeech: { textToSpeech: {
provider: String(tts.provider || 'openai'), provider: String(tts.provider || 'openai'),
quality: String(tts.quality || 'medium'), quality: String(tts.quality || 'medium'),
+109 -37
View File
@@ -59,6 +59,17 @@
border-radius: 12px; border-radius: 12px;
} }
.header-icon-svg {
display: block;
width: 1em;
height: 1em;
}
.header-icon-svg--phone {
width: 22px;
height: 22px;
}
.chat-header-menu-btn { .chat-header-menu-btn {
border-radius: 12px; border-radius: 12px;
font-size: 24px; font-size: 24px;
@@ -4630,6 +4641,7 @@ 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;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06); scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06);
} }
@@ -4657,6 +4669,68 @@ html, body { overflow-x: hidden; }
gap: 10px; gap: 10px;
} }
.dm-history-loader {
position: sticky;
top: 0;
z-index: 3;
display: flex;
justify-content: center;
pointer-events: none;
margin-bottom: -4px;
opacity: 0;
transform: translateY(-8px);
transition: opacity 160ms ease, transform 160ms ease;
}
.dm-history-loader[hidden] {
display: none;
}
.dm-history-loader.is-visible {
opacity: 1;
transform: translateY(0);
}
.dm-history-loader__pill {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 34px;
padding: 7px 14px;
border-radius: 999px;
border: 1px solid rgba(212, 175, 55, 0.24);
background: rgba(9, 15, 26, 0.92);
color: rgba(245, 226, 179, 0.96);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.dm-history-loader__label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
}
.dm-history-loader__spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255, 234, 186, 0.22);
border-top-color: rgba(242, 201, 92, 0.96);
box-shadow: 0 0 10px rgba(212, 175, 55, 0.18);
animation: dm-history-loader-spin 0.85s linear infinite;
}
@keyframes dm-history-loader-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.dm-screen .bubble { .dm-screen .bubble {
max-width: 78%; max-width: 78%;
padding: 11px 14px; padding: 11px 14px;
@@ -4685,12 +4759,13 @@ html, body { overflow-x: hidden; }
.dm-chat-input { .dm-chat-input {
gap: 10px; gap: 10px;
grid-template-columns: 1fr auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: end; align-items: end;
position: sticky; position: sticky;
bottom: 0; bottom: 0;
z-index: 10; z-index: 10;
padding: 10px; margin-inline: -14px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
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);
@@ -4733,18 +4808,35 @@ html, body { overflow-x: hidden; }
.dm-actions-col { .dm-actions-col {
display: grid; display: grid;
grid-template-columns: repeat(2, 42px); grid-template-columns: repeat(2, 46px);
grid-template-rows: 42px; grid-template-rows: 46px;
grid-template-areas: "emoji send"; grid-template-areas: "emoji send";
gap: 6px; gap: 6px;
align-self: end;
}
.dm-chat-input--multiline .dm-actions-col {
grid-template-columns: 46px;
grid-template-rows: repeat(2, 46px);
grid-template-areas:
"emoji"
"send";
} }
.dm-emoji-btn { .dm-emoji-btn {
grid-area: emoji; grid-area: emoji;
min-width: 42px; min-width: 46px;
width: 42px; width: 46px;
padding: 0; padding: 0;
font-size: 20px; line-height: 1;
}
.dm-emoji-btn__glyph {
display: inline-grid;
place-items: center;
width: 100%;
height: 100%;
font-size: 27px;
line-height: 1; line-height: 1;
} }
@@ -4918,6 +5010,8 @@ html, body { overflow-x: hidden; }
.dm-chat-input .dm-input { .dm-chat-input .dm-input {
min-height: 42px; min-height: 42px;
max-height: 180px; max-height: 180px;
width: 100%;
min-width: 0;
resize: none; resize: none;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
@@ -4926,27 +5020,15 @@ html, body { overflow-x: hidden; }
padding: 10px 12px; padding: 10px 12px;
} }
.dm-chat-input--multiline .dm-input {
padding-right: 14px;
}
.dm-chat-input .dm-input::-webkit-scrollbar { .dm-chat-input .dm-input::-webkit-scrollbar {
width: 0; width: 0;
height: 0; height: 0;
} }
.voice-level-wrap {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.15);
overflow: hidden;
}
.voice-level-fill {
width: 2%;
height: 100%;
border-radius: 999px;
background: rgba(212, 175, 55, 0.95);
transition: width 80ms linear;
}
.dm-screen .input, .dm-screen .input,
.dm-input { .dm-input {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
@@ -4970,8 +5052,8 @@ html, body { overflow-x: hidden; }
.dm-send-icon-btn { .dm-send-icon-btn {
grid-area: send; grid-area: send;
min-width: 42px; min-width: 46px;
width: 42px; width: 46px;
padding: 0; padding: 0;
font-size: 15px; font-size: 15px;
line-height: 1; line-height: 1;
@@ -5042,16 +5124,6 @@ html, body { overflow-x: hidden; }
margin-top: 2px; margin-top: 2px;
} }
.speech-actions-top {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.speech-send-now-btn {
width: 100%;
}
.dm-screen .dm-list > .card.meta-muted { .dm-screen .dm-list > .card.meta-muted {
width: calc(100% - 40px); width: calc(100% - 40px);
margin: 0 20px; margin: 0 20px;
@@ -5617,8 +5689,8 @@ html, body { overflow-x: hidden; }
} }
.profile-top-icon-btn img { .profile-top-icon-btn img {
width: 44px; width: 48px;
height: 44px; height: 48px;
display: block; display: block;
opacity: 0.94; opacity: 0.94;
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.34)); filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.34));