SHA256
Добавить ответы в DM и обновить UI чатов
This commit is contained in:
@@ -12,8 +12,10 @@ UI скрывает такие вставки из текста сообщени
|
||||
|
||||
- Если пользователь отправляет обычный текст, начинающийся с `<SHiNE:`, он уходит как `< SHiNE:` и отображается как обычный текст.
|
||||
- DM с `<SHiNE:call...>` показывается в чате как специальное call-сообщение, а не как сырой техтекст.
|
||||
- При исходящем звонке короче `5` секунд call-summary не отправляется вообще.
|
||||
- В списке личных сообщений превью такого сообщения показывает человекочитаемый итог звонка.
|
||||
- DM с `<SHiNE:reply...>Текст ответа` скрывает техблок и показывает только `Текст ответа`.
|
||||
- В меню любого DM первым пунктом есть `Ответить`, и отправка из этого режима реально добавляет `<SHiNE:reply...>` в начало plaintext.
|
||||
- Если reply-цель отсутствует, сообщение всё равно показывается как обычный текст ответа.
|
||||
- Текст вида `<script>alert(1)</script>` или похожие HTML-теги не исполняются ни в чате, ни в списке личных сообщений, ни в списке каналов.
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ fromLogin|toLogin|timeMs|nonce
|
||||
|
||||
- этот блок должен стоять в начале plaintext;
|
||||
- после него может идти обычный текст ответа;
|
||||
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
|
||||
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
|
||||
- в таком случае само сообщение отображается как обычный текст без блока `<SHiNE:reply...>`.
|
||||
|
||||
@@ -123,6 +124,7 @@ fromLogin|toLogin|timeMs|nonce
|
||||
- call-блок в v1 должен содержать только техническую запись звонка;
|
||||
- пользовательский текст после такого блока в штатной логике не предполагается;
|
||||
- UI может рисовать такие сообщения отдельным специальным стилем.
|
||||
- официальный UI не отправляет call-summary, если от старта исходящего звонка до его завершения прошло меньше `5` секунд.
|
||||
|
||||
## 7. Поведение официального UI
|
||||
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.2.308
|
||||
server.version=1.2.287
|
||||
client.version=1.2.309
|
||||
server.version=1.2.288
|
||||
|
||||
@@ -50,6 +50,7 @@ import * as loginCameraView from './pages/login-camera-view.js';
|
||||
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
|
||||
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
|
||||
import * as keyStorageView from './pages/key-storage-view.js';
|
||||
import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
@@ -110,6 +111,7 @@ const routes = {
|
||||
'login-other-device-view': loginOtherDeviceView,
|
||||
'login-password-view': loginPasswordView,
|
||||
'key-storage-view': keyStorageView,
|
||||
queue: publicSupportQueueView,
|
||||
'profile-view': profileView,
|
||||
'profile-edit-view': profileEditView,
|
||||
'wallet-view': walletView,
|
||||
@@ -181,6 +183,7 @@ const GUEST_ALLOWED_PAGES = new Set([
|
||||
'channel-thread-view',
|
||||
'user',
|
||||
'contact-search-view',
|
||||
'queue',
|
||||
]);
|
||||
|
||||
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
||||
|
||||
+103
-11
@@ -20,7 +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';
|
||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
@@ -32,6 +32,12 @@ function truncatePreviewText(value, maxLen = 72) {
|
||||
return `${normalized.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function findMessageByBaseKey(messages, baseKey) {
|
||||
const normalized = String(baseKey || '').trim();
|
||||
if (!normalized) return null;
|
||||
return messages.find((row) => String(row?.baseKey || '').trim() === normalized) || null;
|
||||
}
|
||||
|
||||
function openDeleteMessageConfirmModal({ onConfirm }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
@@ -131,9 +137,11 @@ function openMessageActionsMenu({
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
messageText = '',
|
||||
canReply = false,
|
||||
showReadAloud = true,
|
||||
canEdit = false,
|
||||
canDelete = false,
|
||||
onReply,
|
||||
onReadAloud,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -145,6 +153,7 @@ function openMessageActionsMenu({
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
${canReply ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">Ответить</button>' : ''}
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
|
||||
${showReadAloud ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>' : ''}
|
||||
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
||||
@@ -203,6 +212,11 @@ function openMessageActionsMenu({
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#msg-action-reply')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onReply === 'function') await onReply();
|
||||
});
|
||||
|
||||
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onReadAloud === 'function') await onReadAloud();
|
||||
@@ -492,6 +506,33 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
|
||||
const textNode = document.createElement('div');
|
||||
textNode.className = 'bubble-text';
|
||||
textNode.textContent = parsedText.displayText || '';
|
||||
if (parsedText.replyRef) {
|
||||
const replyTarget = findMessageByBaseKey(messages, parsedText.replyRef.baseKey);
|
||||
if (replyTarget) {
|
||||
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
|
||||
const replyBox = document.createElement('button');
|
||||
replyBox.type = 'button';
|
||||
replyBox.className = 'bubble-reply-preview';
|
||||
|
||||
const replyAuthor = document.createElement('div');
|
||||
replyAuthor.className = 'bubble-reply-preview-author';
|
||||
replyAuthor.textContent = String(replyTarget?.from === 'out' ? state.session.login : parsedText.replyRef.fromLogin);
|
||||
|
||||
const replyText = document.createElement('div');
|
||||
replyText.className = 'bubble-reply-preview-text';
|
||||
replyText.textContent = truncatePreviewText(replyParsed.displayText || '', 96);
|
||||
|
||||
replyBox.append(replyAuthor, replyText);
|
||||
replyBox.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const targetNode = list.querySelector(`[data-base-key="${CSS.escape(String(replyTarget?.baseKey || ''))}"]`);
|
||||
if (targetNode?.scrollIntoView) {
|
||||
targetNode.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
bubble.append(replyBox);
|
||||
}
|
||||
}
|
||||
bubble.append(textNode);
|
||||
|
||||
const metaNode = document.createElement('div');
|
||||
@@ -523,6 +564,7 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
|
||||
bubble.addEventListener('click', (event) => {
|
||||
if (typeof onOpenActions === 'function') onOpenActions(msg, event);
|
||||
});
|
||||
bubble.dataset.baseKey = String(msg?.baseKey || '');
|
||||
list.append(bubble);
|
||||
});
|
||||
if (scrollMode === 'unread' && !scrollToUnreadSeparator(list)) {
|
||||
@@ -764,6 +806,7 @@ export function render({ navigate, route }) {
|
||||
const editBannerText = form.querySelector('#chat-edit-banner-text');
|
||||
const editCancelBtn = form.querySelector('#chat-edit-cancel');
|
||||
let activeEdit = null;
|
||||
let activeReply = null;
|
||||
let inputFocused = false;
|
||||
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||
|
||||
@@ -778,13 +821,17 @@ export function render({ navigate, route }) {
|
||||
|
||||
const syncEditBanner = () => {
|
||||
if (!editBanner || !editBannerText) return;
|
||||
if (!activeEdit) {
|
||||
if (!activeEdit && !activeReply) {
|
||||
editBanner.hidden = true;
|
||||
editBannerText.textContent = '';
|
||||
return;
|
||||
}
|
||||
editBanner.hidden = false;
|
||||
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
|
||||
if (activeEdit) {
|
||||
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
|
||||
return;
|
||||
}
|
||||
editBannerText.textContent = `Ответить\n${truncatePreviewText(activeReply?.previewText || '', 110)}`;
|
||||
};
|
||||
|
||||
const focusInputToEnd = () => {
|
||||
@@ -810,25 +857,53 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
};
|
||||
|
||||
const cancelReplyMode = ({ restoreDraft = true } = {}) => {
|
||||
if (!activeReply) return;
|
||||
activeReply = null;
|
||||
syncEditBanner();
|
||||
if (restoreDraft && input) {
|
||||
autoResizeComposer(input);
|
||||
focusInputToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const startEditMode = (msg) => {
|
||||
const base = parseBaseKey(msg?.baseKey);
|
||||
if (!base || msg?.from !== 'out') return;
|
||||
const parsed = parseDmTechBlocks(String(msg?.text || ''));
|
||||
const currentDraft = String(input?.value || '');
|
||||
activeReply = null;
|
||||
activeEdit = {
|
||||
messageKey: String(msg?.messageKey || ''),
|
||||
originalText: String(msg?.text || ''),
|
||||
originalText: String(parsed?.displayText || ''),
|
||||
prefixText: String(parsed?.prefixText || ''),
|
||||
draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft,
|
||||
timeMs: base.timeMs,
|
||||
nonce: base.nonce,
|
||||
};
|
||||
syncEditBanner();
|
||||
if (input) {
|
||||
input.value = String(msg?.text || '');
|
||||
input.value = String(parsed?.visibleText || '');
|
||||
autoResizeComposer(input);
|
||||
focusInputToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const startReplyMode = (msg) => {
|
||||
const base = parseBaseKey(msg?.baseKey);
|
||||
if (!base) return;
|
||||
const parsed = parseDmTechBlocks(String(msg?.text || ''));
|
||||
activeEdit = null;
|
||||
activeReply = {
|
||||
baseKey: String(msg?.baseKey || ''),
|
||||
fromLogin: base.fromLogin,
|
||||
toLogin: base.toLogin,
|
||||
previewText: String(parsed?.displayText || parsed?.visibleText || ''),
|
||||
};
|
||||
syncEditBanner();
|
||||
focusInputToEnd();
|
||||
};
|
||||
|
||||
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||||
if (!localOutgoingBlobB64) return;
|
||||
try {
|
||||
@@ -892,9 +967,14 @@ export function render({ navigate, route }) {
|
||||
};
|
||||
|
||||
const sendTextMessage = async (rawText) => {
|
||||
const text = sanitizeUserDmTextForSend(String(rawText || '')).trim();
|
||||
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
||||
const text = safeText.trim();
|
||||
if (!text) return;
|
||||
const editing = activeEdit;
|
||||
const replying = !editing ? activeReply : null;
|
||||
const finalText = editing
|
||||
? `${String(editing?.prefixText || '')}${text}`
|
||||
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
|
||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||
@@ -905,7 +985,7 @@ export function render({ navigate, route }) {
|
||||
result = await authService.sendDirectMessageRevision({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
text,
|
||||
text: finalText,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
timeMs: editing.timeMs,
|
||||
nonce: editing.nonce,
|
||||
@@ -915,7 +995,7 @@ export function render({ navigate, route }) {
|
||||
result = await authService.sendDirectMessage({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
text,
|
||||
text: finalText,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
markOutgoingSent(tempId, {
|
||||
@@ -932,6 +1012,8 @@ export function render({ navigate, route }) {
|
||||
|
||||
if (editing) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
} else if (replying) {
|
||||
cancelReplyMode({ restoreDraft: false });
|
||||
}
|
||||
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
@@ -957,6 +1039,7 @@ export function render({ navigate, route }) {
|
||||
messageId: result?.outgoingKey || '',
|
||||
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
|
||||
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
|
||||
replyToBaseKey: replying?.baseKey || '',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -980,16 +1063,21 @@ export function render({ navigate, route }) {
|
||||
};
|
||||
|
||||
const handleOpenActions = (msg, event) => {
|
||||
const parsed = parseDmTechBlocks(String(msg?.text || ''));
|
||||
openMessageActionsMenu({
|
||||
anchorX: Number(event?.clientX || 0),
|
||||
anchorY: Number(event?.clientY || 0),
|
||||
messageText: msg?.text || '',
|
||||
messageText: parsed?.displayText || msg?.text || '',
|
||||
canReply: true,
|
||||
showReadAloud: isTextToSpeechReady,
|
||||
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||||
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
|
||||
canDelete: (
|
||||
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|
||||
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
|
||||
),
|
||||
onReply: async () => {
|
||||
startReplyMode(msg);
|
||||
},
|
||||
onReadAloud: async () => handleReadAloud(msg),
|
||||
onEdit: async () => {
|
||||
startEditMode(msg);
|
||||
@@ -1009,7 +1097,11 @@ export function render({ navigate, route }) {
|
||||
};
|
||||
|
||||
editCancelBtn?.addEventListener('click', () => {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
if (activeEdit) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
return;
|
||||
}
|
||||
cancelReplyMode({ restoreDraft: true });
|
||||
});
|
||||
|
||||
autoResizeComposer(input);
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||
|
||||
export const pageMeta = { id: 'queue', title: 'Очередь билета' };
|
||||
|
||||
const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v';
|
||||
const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE';
|
||||
const SHINE_PAYMENTS_SEEDS = {
|
||||
config: 'shine_payments_config',
|
||||
coef: 'shine_payments_coef_limit',
|
||||
queues: 'shine_payments_queues',
|
||||
q1: 'shine_payments_q1_ticket',
|
||||
q2: 'shine_payments_q2_ticket',
|
||||
q3: 'shine_payments_q3_ticket',
|
||||
};
|
||||
|
||||
function utf8Bytes(text) {
|
||||
return new TextEncoder().encode(String(text || ''));
|
||||
}
|
||||
|
||||
function u64ToBytes(value) {
|
||||
const out = new Uint8Array(8);
|
||||
let current = BigInt(value || 0);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
out[i] = Number(current & 0xffn);
|
||||
current >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readU64(data, offset) {
|
||||
let value = 0n;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
value |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readI64(data, offset) {
|
||||
let value = readU64(data, offset);
|
||||
if (value > 0x7fffffffffffffffn) {
|
||||
value -= 0x10000000000000000n;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readI32(data, offset) {
|
||||
let value = Number(readU64(data, offset) & 0xffffffffn);
|
||||
if (value > 0x7fffffff) value -= 0x100000000;
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatQueuePrefix(queueId) {
|
||||
if (queueId === 2) return 'Q2';
|
||||
if (queueId === 3) return 'Q3';
|
||||
return 'Q1';
|
||||
}
|
||||
|
||||
function formatUsdCentsText(cents) {
|
||||
const value = Number(cents || 0n) / 100;
|
||||
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function parsePaymentsCoef(data) {
|
||||
let offset = 0;
|
||||
const version = data[offset];
|
||||
offset += 1;
|
||||
const coefPpm = readU64(data, offset);
|
||||
offset += 8;
|
||||
const limitUsdCents = readU64(data, offset);
|
||||
offset += 8;
|
||||
const callRewardLamports = readU64(data, offset);
|
||||
return { version, coefPpm, limitUsdCents, callRewardLamports };
|
||||
}
|
||||
|
||||
function parsePaymentsQueues(data) {
|
||||
let offset = 0;
|
||||
const version = data[offset];
|
||||
offset += 1;
|
||||
const q1TicketsTotal = readU64(data, offset); offset += 8;
|
||||
const q1TicketsPaid = readU64(data, offset); offset += 8;
|
||||
const q1SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||
const q1SumPaidUsdCents = readU64(data, offset); offset += 8;
|
||||
const q2TicketsTotal = readU64(data, offset); offset += 8;
|
||||
const q2TicketsPaid = readU64(data, offset); offset += 8;
|
||||
const q2SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||
const q2SumPaidUsdCents = readU64(data, offset); offset += 8;
|
||||
const q3TicketsTotal = readU64(data, offset); offset += 8;
|
||||
const q3TicketsPaid = readU64(data, offset); offset += 8;
|
||||
const q3SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||
const q3SumPaidUsdCents = readU64(data, offset);
|
||||
return {
|
||||
version,
|
||||
q1TicketsTotal,
|
||||
q1TicketsPaid,
|
||||
q1SumTotalUsdCents,
|
||||
q1SumPaidUsdCents,
|
||||
q2TicketsTotal,
|
||||
q2TicketsPaid,
|
||||
q2SumTotalUsdCents,
|
||||
q2SumPaidUsdCents,
|
||||
q3TicketsTotal,
|
||||
q3TicketsPaid,
|
||||
q3SumTotalUsdCents,
|
||||
q3SumPaidUsdCents,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePaymentsTicket(data) {
|
||||
let offset = 0;
|
||||
const version = data[offset];
|
||||
offset += 1;
|
||||
const queueId = data[offset];
|
||||
offset += 1;
|
||||
const index = readU64(data, offset);
|
||||
offset += 8;
|
||||
const isPaid = Boolean(data[offset]);
|
||||
offset += 1;
|
||||
const solana = window.solanaWeb3;
|
||||
const recipientWallet = new solana.PublicKey(data.slice(offset, offset + 32)).toBase58();
|
||||
offset += 32;
|
||||
const payoutUsdCents = readU64(data, offset);
|
||||
offset += 8;
|
||||
const debtBeforeUsdCents = readU64(data, offset);
|
||||
return {
|
||||
version,
|
||||
queueId,
|
||||
index,
|
||||
isPaid,
|
||||
recipientWallet,
|
||||
payoutUsdCents,
|
||||
debtBeforeUsdCents,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSupportTicketInput(rawValue) {
|
||||
const clean = String(rawValue || '').trim();
|
||||
if (!clean) throw new Error('Введите номер билета.');
|
||||
if (/^\d+$/.test(clean)) {
|
||||
return { queueId: 1, index: BigInt(clean) };
|
||||
}
|
||||
const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.');
|
||||
}
|
||||
return {
|
||||
queueId: Number(match[1]),
|
||||
index: BigInt(match[2]),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSupportPaymentsCore(endpoint) {
|
||||
const solana = await loadSolanaWeb3();
|
||||
const rpc = String(endpoint || '').trim();
|
||||
const connection = new solana.Connection(rpc, 'confirmed');
|
||||
const programId = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
|
||||
const oracleAccount = new solana.PublicKey(SHINE_PAYMENTS_ORACLE_ACCOUNT);
|
||||
const [configPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.config)], programId);
|
||||
const [coefPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.coef)], programId);
|
||||
const [queuesPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.queues)], programId);
|
||||
const [oracleAi, configAi, coefAi, queuesAi] = await Promise.all([
|
||||
connection.getAccountInfo(oracleAccount, 'confirmed'),
|
||||
connection.getAccountInfo(configPda, 'confirmed'),
|
||||
connection.getAccountInfo(coefPda, 'confirmed'),
|
||||
connection.getAccountInfo(queuesPda, 'confirmed'),
|
||||
]);
|
||||
if (!oracleAi) throw new Error('Не найден аккаунт оракула SOL/USD.');
|
||||
if (!configAi || !coefAi || !queuesAi) throw new Error('PDA программы оплаты ещё не инициализированы.');
|
||||
|
||||
const parsePrice = (data) => {
|
||||
const price = readI64(data, 73);
|
||||
const exponent = readI32(data, 89);
|
||||
const publishTime = readI64(data, 93);
|
||||
if (price <= 0n) throw new Error('Оракул вернул некорректную цену.');
|
||||
let num = price * 100n;
|
||||
let den = 1n;
|
||||
if (exponent >= 0) {
|
||||
num *= 10n ** BigInt(exponent);
|
||||
} else {
|
||||
den *= 10n ** BigInt(-exponent);
|
||||
}
|
||||
return { priceNum: num, priceDen: den, publishTime };
|
||||
};
|
||||
|
||||
return {
|
||||
connection,
|
||||
programId,
|
||||
coef: parsePaymentsCoef(coefAi.data),
|
||||
queues: parsePaymentsQueues(queuesAi.data),
|
||||
pyth: parsePrice(oracleAi.data),
|
||||
};
|
||||
}
|
||||
|
||||
function queueStateView(queues, queueId) {
|
||||
if (queueId === 2) {
|
||||
return {
|
||||
ticketsTotal: queues.q2TicketsTotal,
|
||||
ticketsPaid: queues.q2TicketsPaid,
|
||||
sumTotalUsdCents: queues.q2SumTotalUsdCents,
|
||||
sumPaidUsdCents: queues.q2SumPaidUsdCents,
|
||||
};
|
||||
}
|
||||
if (queueId === 3) {
|
||||
return {
|
||||
ticketsTotal: queues.q3TicketsTotal,
|
||||
ticketsPaid: queues.q3TicketsPaid,
|
||||
sumTotalUsdCents: queues.q3SumTotalUsdCents,
|
||||
sumPaidUsdCents: queues.q3SumPaidUsdCents,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ticketsTotal: queues.q1TicketsTotal,
|
||||
ticketsPaid: queues.q1TicketsPaid,
|
||||
sumTotalUsdCents: queues.q1SumTotalUsdCents,
|
||||
sumPaidUsdCents: queues.q1SumPaidUsdCents,
|
||||
};
|
||||
}
|
||||
|
||||
function queueSeedFor(queueId) {
|
||||
if (queueId === 2) return SHINE_PAYMENTS_SEEDS.q2;
|
||||
if (queueId === 3) return SHINE_PAYMENTS_SEEDS.q3;
|
||||
return SHINE_PAYMENTS_SEEDS.q1;
|
||||
}
|
||||
|
||||
function styleInputField(field) {
|
||||
if (!field) return;
|
||||
field.style.color = '#111111';
|
||||
field.style.webkitTextFillColor = '#111111';
|
||||
field.style.caretColor = '#111111';
|
||||
field.style.backgroundColor = '#ffffff';
|
||||
}
|
||||
|
||||
function readTicketFromUrl() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
return String(params.get('ticket') || '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'stack';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'meta-muted';
|
||||
status.textContent = 'Страница готова. Введите номер билета.';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<h2 style="margin:0;">Публичная очередь билета</h2>
|
||||
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||
Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате <code>2-15</code> или <code>3 8</code>.
|
||||
Страница покажет состояние билета и сколько позиций осталось до выплаты.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const inputLabel = document.createElement('label');
|
||||
inputLabel.className = 'meta-muted';
|
||||
inputLabel.setAttribute('for', 'public-queue-ticket-query');
|
||||
inputLabel.textContent = 'Номер билета';
|
||||
|
||||
const queryInput = document.createElement('input');
|
||||
queryInput.id = 'public-queue-ticket-query';
|
||||
queryInput.type = 'text';
|
||||
queryInput.placeholder = 'Например: 12, 2-5 или 3 8';
|
||||
queryInput.autocomplete = 'off';
|
||||
queryInput.spellcheck = false;
|
||||
styleInputField(queryInput);
|
||||
queryInput.value = readTicketFromUrl();
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'row';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="public-queue-load" style="width:100%;">Показать билет</button>
|
||||
<button class="ghost-btn" type="button" id="public-queue-reset" style="width:100%;">Сбросить</button>
|
||||
`;
|
||||
|
||||
const result = document.createElement('div');
|
||||
result.className = 'card stack';
|
||||
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
|
||||
|
||||
let lastCoreState = null;
|
||||
|
||||
async function renderTicketInfo() {
|
||||
const raw = String(queryInput.value || '').trim();
|
||||
if (!raw) {
|
||||
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета.</p>`;
|
||||
status.textContent = 'Введите номер билета.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = parseSupportTicketInput(raw);
|
||||
const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||
lastCoreState = core;
|
||||
const solana = await loadSolanaWeb3();
|
||||
const ticketPdaSeed = queueSeedFor(parsed.queueId);
|
||||
const ticketIndexBytes = u64ToBytes(parsed.index);
|
||||
const [ticketPda] = solana.PublicKey.findProgramAddressSync(
|
||||
[utf8Bytes(ticketPdaSeed), ticketIndexBytes],
|
||||
core.programId,
|
||||
);
|
||||
const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed');
|
||||
if (!ticketInfo) {
|
||||
result.innerHTML = `
|
||||
<p class="meta-muted" style="margin:0;">Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.</p>
|
||||
`;
|
||||
status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} не найден.`;
|
||||
return;
|
||||
}
|
||||
const ticket = parsePaymentsTicket(ticketInfo.data);
|
||||
const queueState = queueStateView(core.queues, parsed.queueId);
|
||||
const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n;
|
||||
const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore;
|
||||
const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n;
|
||||
result.innerHTML = `
|
||||
<div><b>Билет:</b> ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}</div>
|
||||
<div><b>Статус:</b> ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}</div>
|
||||
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
|
||||
<div><b>До него в очереди:</b> ${ticketsBefore.toString()} билетов</div>
|
||||
<div><b>Из них уже выплачено:</b> ${paidBefore.toString()} билетов</div>
|
||||
<div><b>Ещё осталось до него:</b> ${remainingBefore.toString()} билетов</div>
|
||||
<div><b>Уже выплачено по сумме в очереди:</b> ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD</div>
|
||||
<div><b>Сумма этого билета:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
|
||||
<div><b>Накоплено до этого билета:</b> ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него</div>
|
||||
`;
|
||||
status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`;
|
||||
} catch (error) {
|
||||
result.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить билет')}</p>`;
|
||||
status.textContent = `Не удалось посмотреть билет: ${error?.message || 'unknown'}`;
|
||||
}
|
||||
}
|
||||
|
||||
actions.querySelector('#public-queue-load')?.addEventListener('click', () => {
|
||||
void renderTicketInfo();
|
||||
});
|
||||
actions.querySelector('#public-queue-reset')?.addEventListener('click', () => {
|
||||
queryInput.value = '';
|
||||
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
|
||||
status.textContent = 'Поле билета очищено.';
|
||||
});
|
||||
queryInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void renderTicketInfo();
|
||||
}
|
||||
});
|
||||
|
||||
content.append(card, inputLabel, queryInput, actions, result, status);
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Очередь билета',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
}),
|
||||
content,
|
||||
);
|
||||
|
||||
if (queryInput.value) {
|
||||
void renderTicketInfo();
|
||||
}
|
||||
|
||||
return screen;
|
||||
}
|
||||
@@ -152,6 +152,10 @@ export function getRoute() {
|
||||
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'queue') {
|
||||
return { pageId, params: {} };
|
||||
}
|
||||
|
||||
return { pageId, params: {} };
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ let toneFlip = false;
|
||||
const DEFAULT_ICE_SERVERS = Object.freeze([
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
]);
|
||||
const CALL_SUMMARY_MIN_TOTAL_MS = 5000;
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
@@ -167,6 +168,8 @@ async function applyLocalOutgoingDmCopy(peerLogin, result) {
|
||||
|
||||
async function sendOutgoingCallSummaryDm(call, summaryCode) {
|
||||
if (!call || call.direction !== 'out' || !call.peerLogin) return;
|
||||
const totalMs = Math.max(0, nowMs() - Number(call.startedAtMs || nowMs()));
|
||||
if (totalMs < CALL_SUMMARY_MIN_TOTAL_MS) return;
|
||||
const text = buildOutgoingCallSummaryText(call, summaryCode);
|
||||
if (!text) return;
|
||||
const login = String(state?.session?.login || '').trim();
|
||||
|
||||
@@ -2,6 +2,7 @@ function defaultParsed(rawText = '') {
|
||||
const text = String(rawText || '');
|
||||
return {
|
||||
rawText: text,
|
||||
prefixText: '',
|
||||
visibleText: text,
|
||||
displayText: text,
|
||||
blocks: [],
|
||||
@@ -68,6 +69,12 @@ export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = ''
|
||||
return `<SHiNE:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
|
||||
}
|
||||
|
||||
export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
|
||||
const cleanBaseKey = String(baseKey || '').trim();
|
||||
if (!cleanBaseKey) return '';
|
||||
return `<SHiNE:reply;v=1;id=${cleanBaseKey}>`;
|
||||
}
|
||||
|
||||
export function parseDmTechBlocks(rawText = '') {
|
||||
const text = String(rawText || '');
|
||||
if (!text.startsWith('<SHiNE:')) return defaultParsed(text);
|
||||
@@ -127,6 +134,7 @@ export function parseDmTechBlocks(rawText = '') {
|
||||
const displayText = callSummary ? buildCallDisplayText(callSummary) : visibleText;
|
||||
return {
|
||||
rawText: text,
|
||||
prefixText: text.slice(0, cursor),
|
||||
visibleText,
|
||||
displayText,
|
||||
blocks,
|
||||
|
||||
@@ -991,6 +991,35 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bubble-reply-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 0 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(142, 173, 232, 0.22);
|
||||
border-radius: 10px;
|
||||
background: rgba(11, 22, 41, 0.34);
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bubble-reply-preview-author {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(250, 221, 155, 0.94);
|
||||
}
|
||||
|
||||
.bubble-reply-preview-text {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
color: rgba(223, 233, 250, 0.86);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bubble-meta {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
@@ -3955,9 +3984,8 @@ html, body { overflow-x: hidden; }
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dm-edit-banner__close {
|
||||
|
||||
Reference in New Issue
Block a user