SHA256
Добавить SHiNE теги в DM и безопасный рендер превью
This commit is contained in:
+4
-1
@@ -15,6 +15,7 @@ import {
|
||||
startDebugConnectionAsInitiator,
|
||||
startDebugConnectionAsResponder,
|
||||
} from './services/call-service.js';
|
||||
import { parseDmTechBlocks } from './services/dm-tech-blocks.js';
|
||||
import {
|
||||
authService,
|
||||
addAppLogEntry,
|
||||
@@ -1154,11 +1155,13 @@ async function init() {
|
||||
shouldRefreshToolbarUnread = true;
|
||||
}
|
||||
if (added && isIncomingForCurrent && !payload.backlog) {
|
||||
const parsedText = parseDmTechBlocks(text);
|
||||
const displayText = String(parsedText.displayText || '');
|
||||
if (document.visibilityState === 'visible') {
|
||||
void playDmSignal({ extended: false });
|
||||
} else if (Notification.permission === 'granted') {
|
||||
try {
|
||||
void notifyHiddenIncomingMessage(fromLogin, text || '');
|
||||
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
|
||||
} catch {}
|
||||
} else {
|
||||
void playDmSignal({ extended: true });
|
||||
|
||||
@@ -20,6 +20,7 @@ import { startOutgoingCall } from '../services/call-service.js';
|
||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||
import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
@@ -483,12 +484,14 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
|
||||
}
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
const bubbleKind = String(msg?.kind || '').trim();
|
||||
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
|
||||
const autoKind = parsedText.callSummary ? 'call-tech' : '';
|
||||
const bubbleKind = String(msg?.kind || autoKind || '').trim();
|
||||
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
|
||||
|
||||
const textNode = document.createElement('div');
|
||||
textNode.className = 'bubble-text';
|
||||
textNode.textContent = msg.text || '';
|
||||
textNode.textContent = parsedText.displayText || '';
|
||||
bubble.append(textNode);
|
||||
|
||||
const metaNode = document.createElement('div');
|
||||
@@ -582,7 +585,8 @@ export function render({ navigate, route }) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
|
||||
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
|
||||
};
|
||||
|
||||
const handleStartCall = async () => {
|
||||
@@ -888,7 +892,7 @@ export function render({ navigate, route }) {
|
||||
};
|
||||
|
||||
const sendTextMessage = async (rawText) => {
|
||||
const text = String(rawText || '').trim();
|
||||
const text = sanitizeUserDmTextForSend(String(rawText || '')).trim();
|
||||
if (!text) return;
|
||||
const editing = activeEdit;
|
||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const dmAvatarSnapshotCache = new Map();
|
||||
@@ -64,6 +65,12 @@ function createDmAvatar(login) {
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
function resolveLastMessagePreview(text = '') {
|
||||
const parsed = parseDmTechBlocks(String(text || ''));
|
||||
const display = String(parsed.displayText || '').trim();
|
||||
return display || '';
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
const value = Number(ts || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '';
|
||||
@@ -88,12 +95,14 @@ export function render({ navigate }) {
|
||||
<div class="dm-head-brand">
|
||||
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
|
||||
<div class="dm-head-id">
|
||||
<span class="dm-head-name">${login}</span>
|
||||
<span class="dm-head-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="dm-head-title">Контакты</h1>
|
||||
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
|
||||
`;
|
||||
const headName = head.querySelector('.dm-head-name');
|
||||
if (headName) headName.textContent = login;
|
||||
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
|
||||
|
||||
const divider = document.createElement('div');
|
||||
@@ -113,19 +122,25 @@ export function render({ navigate }) {
|
||||
row.innerHTML = `
|
||||
<div class="dm-row-main">
|
||||
<div class="dm-row-titleline dm-row-titlewrap">
|
||||
<strong class="dm-row-title">${item.name}</strong>
|
||||
<strong class="dm-row-title"></strong>
|
||||
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
|
||||
</div>
|
||||
<p class="dm-row-last-message">${item.lastMessage}</p>
|
||||
<p class="dm-row-last-message"></p>
|
||||
</div>
|
||||
<div class="dm-row-meta-col">
|
||||
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
||||
<div class="dm-row-meta-line">
|
||||
${item.time ? `<span class="dm-row-time">${item.time}</span>` : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||
${item.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const titleEl = row.querySelector('.dm-row-title');
|
||||
const previewEl = row.querySelector('.dm-row-last-message');
|
||||
const timeEl = row.querySelector('.dm-row-time');
|
||||
if (titleEl) titleEl.textContent = String(item.name || '');
|
||||
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
|
||||
if (timeEl) timeEl.textContent = String(item.time || '');
|
||||
row.prepend(avatarWrap);
|
||||
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(normalizeDmChatId(item.id))}`));
|
||||
return row;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { addSignedMessageToChat, authService, authorizeSession, state } from '../state.js';
|
||||
import { buildDmCallTechBlock } from './dm-tech-blocks.js';
|
||||
|
||||
const TYPES = {
|
||||
INVITE: 100,
|
||||
@@ -113,23 +114,25 @@ function isInviteUndelivered(call) {
|
||||
function buildOutgoingCallSummaryText(call, summaryCode) {
|
||||
if (!call || call.direction !== 'out') return '';
|
||||
if (summaryCode === 'completed') {
|
||||
const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs()));
|
||||
return `Звонок: ${duration}`;
|
||||
return buildDmCallTechBlock({
|
||||
status: 'completed',
|
||||
durationSec: Math.round(Math.max(0, nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())) / 1000),
|
||||
});
|
||||
}
|
||||
if (summaryCode === 'busy') {
|
||||
return 'Звонил, но недозвонился: абонент занят';
|
||||
return buildDmCallTechBlock({ status: 'failed', reason: 'busy' });
|
||||
}
|
||||
if (summaryCode === 'declined') {
|
||||
return 'Звонил, но недозвонился: звонок отклонён';
|
||||
return buildDmCallTechBlock({ status: 'failed', reason: 'declined' });
|
||||
}
|
||||
if (summaryCode === 'no_answer') {
|
||||
if (isInviteUndelivered(call)) {
|
||||
return 'Звонил, но недозвонился: абонент не в сети';
|
||||
return buildDmCallTechBlock({ status: 'failed', reason: 'offline' });
|
||||
}
|
||||
return 'Звонил, но недозвонился: нет ответа';
|
||||
return buildDmCallTechBlock({ status: 'failed', reason: 'no_answer' });
|
||||
}
|
||||
if (summaryCode === 'error') {
|
||||
return 'Звонил, но недозвонился: не удалось установить соединение';
|
||||
return buildDmCallTechBlock({ status: 'failed', reason: 'connect_failed' });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
function defaultParsed(rawText = '') {
|
||||
const text = String(rawText || '');
|
||||
return {
|
||||
rawText: text,
|
||||
visibleText: text,
|
||||
displayText: text,
|
||||
blocks: [],
|
||||
replyRef: null,
|
||||
callSummary: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseReplyId(value = '') {
|
||||
const parts = String(value || '').split('|');
|
||||
if (parts.length !== 4) return null;
|
||||
const fromLogin = String(parts[0] || '').trim();
|
||||
const toLogin = String(parts[1] || '').trim();
|
||||
const timeMs = Number(parts[2] || 0);
|
||||
const nonce = Number(parts[3] || 0);
|
||||
if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || timeMs <= 0 || !Number.isFinite(nonce) || nonce < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fromLogin,
|
||||
toLogin,
|
||||
timeMs,
|
||||
nonce,
|
||||
baseKey: `${fromLogin}|${toLogin}|${timeMs}|${nonce}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCallDuration(totalSeconds) {
|
||||
const total = Math.max(0, Math.floor(Number(totalSeconds || 0)));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function buildCallDisplayText(callSummary) {
|
||||
if (!callSummary) return '';
|
||||
if (callSummary.status === 'completed') {
|
||||
return `Звонок: ${formatCallDuration(callSummary.durationSec)}`;
|
||||
}
|
||||
const reason = String(callSummary.reason || '').trim().toLowerCase();
|
||||
if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети';
|
||||
if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа';
|
||||
if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение';
|
||||
if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят';
|
||||
if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён';
|
||||
return 'Звонил, но недозвонился';
|
||||
}
|
||||
|
||||
export function sanitizeUserDmTextForSend(rawText = '') {
|
||||
const text = String(rawText || '');
|
||||
return text.startsWith('<SHiNE:') ? `< SHiNE:${text.slice(7)}` : text;
|
||||
}
|
||||
|
||||
export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = '' } = {}) {
|
||||
const cleanStatus = String(status || '').trim().toLowerCase();
|
||||
if (cleanStatus === 'completed') {
|
||||
return `<SHiNE:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
|
||||
}
|
||||
const cleanReason = String(reason || '').trim().toLowerCase();
|
||||
return `<SHiNE:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
|
||||
}
|
||||
|
||||
export function parseDmTechBlocks(rawText = '') {
|
||||
const text = String(rawText || '');
|
||||
if (!text.startsWith('<SHiNE:')) return defaultParsed(text);
|
||||
|
||||
const blocks = [];
|
||||
let cursor = 0;
|
||||
let replyRef = null;
|
||||
let callSummary = null;
|
||||
|
||||
while (text.startsWith('<SHiNE:', cursor)) {
|
||||
const end = text.indexOf('>', cursor);
|
||||
if (end < 0) {
|
||||
if (!blocks.length) return defaultParsed(text);
|
||||
break;
|
||||
}
|
||||
|
||||
const inner = text.slice(cursor + 7, end);
|
||||
const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean);
|
||||
if (!segments.length) {
|
||||
if (!blocks.length) return defaultParsed(text);
|
||||
break;
|
||||
}
|
||||
|
||||
const kind = String(segments[0] || '').trim().toLowerCase();
|
||||
const fields = {};
|
||||
for (let i = 1; i < segments.length; i += 1) {
|
||||
const part = segments[i];
|
||||
const eq = part.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = part.slice(0, eq).trim().toLowerCase();
|
||||
const value = part.slice(eq + 1).trim();
|
||||
if (key) fields[key] = value;
|
||||
}
|
||||
|
||||
const block = { kind, version: Number(fields.v || 0), fields };
|
||||
blocks.push(block);
|
||||
|
||||
if (kind === 'reply' && !replyRef) {
|
||||
replyRef = parseReplyId(fields.id || '');
|
||||
} else if (kind === 'call' && !callSummary) {
|
||||
const status = String(fields.status || '').trim().toLowerCase();
|
||||
if (status === 'completed') {
|
||||
const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0)));
|
||||
callSummary = { status: 'completed', durationSec };
|
||||
} else {
|
||||
callSummary = {
|
||||
status: 'failed',
|
||||
reason: String(fields.reason || '').trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cursor = end + 1;
|
||||
}
|
||||
|
||||
const visibleText = text.slice(cursor);
|
||||
const displayText = callSummary ? buildCallDisplayText(callSummary) : visibleText;
|
||||
return {
|
||||
rawText: text,
|
||||
visibleText,
|
||||
displayText,
|
||||
blocks,
|
||||
replyRef,
|
||||
callSummary,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user