- Implemented across bootstrap.php, chat frontend, and dashboard:
Per-tier audio bitrate: Free 64, Plus 96, Pro 128 kbps. Configurable text/voice reply threads and depth. Registration/login-only honeypot and internal CAPTCHA. Optional Google reCAPTCHA v2/v3 with server verification. Archive/export support for replies and bitrate. Dashboard controls for all settings. Verified JavaScript, JSON, SQLite migrations, desktop/mobile UI, and browser console. Native PHP lint was unavailable because PHP is not installed. Google implementation follows Siteverify and reCAPTCHA v3.
This commit is contained in:
+275
-23
@@ -13,6 +13,8 @@
|
||||
},
|
||||
loginChallenge: initialChallenge,
|
||||
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||
reply: null,
|
||||
captcha: { internal: {}, widgets: {}, scriptPromise: null },
|
||||
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
||||
chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false,
|
||||
playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
|
||||
@@ -20,7 +22,7 @@
|
||||
let root;
|
||||
let wakeListenersBound = false;
|
||||
const displayedFeatures = new Set([
|
||||
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play',
|
||||
'voice_messages', 'voice_bitrate_kbps', 'recording_status', 'auto_voice_send', 'auto_voice_play',
|
||||
'username_color', 'text_archive_days', 'text_export', 'voice_archive',
|
||||
'voice_export', 'email_2fa'
|
||||
]);
|
||||
@@ -151,9 +153,11 @@
|
||||
state.config = await api('config.php').catch(() => ({
|
||||
chat: {
|
||||
max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000,
|
||||
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500
|
||||
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500,
|
||||
replies_enabled: true, reply_max_depth: 4
|
||||
},
|
||||
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
|
||||
security: { min_password_length: 4, captcha: {} },
|
||||
voice: { enabled: true, max_duration_seconds: 60 },
|
||||
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
|
||||
}));
|
||||
if (!wakeListenersBound) {
|
||||
@@ -192,8 +196,141 @@
|
||||
});
|
||||
}
|
||||
|
||||
function authCaptchaEnabled(operation) {
|
||||
const captcha = state.config.security?.captcha || {};
|
||||
return operation === 'register'
|
||||
? captcha.registration_enabled === true
|
||||
: captcha.login_enabled === true;
|
||||
}
|
||||
function authCaptchaMarkup(operation) {
|
||||
if (!authCaptchaEnabled(operation)) return '';
|
||||
const captcha = state.config.security?.captcha || {};
|
||||
return `${captcha.honeypot_enabled ? `<div class="cc-captcha-trap" aria-hidden="true">
|
||||
<label>Website<input id="${operation}-hp-website" tabindex="-1" autocomplete="off"></label>
|
||||
<label><input id="${operation}-hp-contact" type="checkbox" tabindex="-1"> Contact me</label>
|
||||
</div>` : ''}
|
||||
<div class="cc-captcha-block">
|
||||
${captcha.internal_enabled ? `<div class="cc-internal-captcha">
|
||||
<label class="cc-field-label">HUMAN CHECK</label>
|
||||
<div class="cc-captcha-row"><span id="${operation}-captcha-question">LOADING...</span>
|
||||
<input class="cc-input" id="${operation}-captcha-answer" inputmode="numeric" autocomplete="off" aria-label="CAPTCHA answer">
|
||||
<button class="cc-captcha-refresh" id="${operation}-captcha-refresh" type="button">NEW</button></div>
|
||||
</div>` : ''}
|
||||
${captcha.google_enabled && captcha.google_version === 'v2'
|
||||
? `<div class="cc-google-captcha" id="${operation}-google-captcha"></div>` : ''}
|
||||
${captcha.google_enabled && captcha.google_version === 'v3'
|
||||
? '<div class="cc-captcha-note">Protected by reCAPTCHA v3</div>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
async function loadInternalCaptcha(operation) {
|
||||
if (!authCaptchaEnabled(operation) || !state.config.security?.captcha?.internal_enabled) return;
|
||||
const result = await api('auth.php', null, new URLSearchParams({
|
||||
action: 'captcha_challenge', operation
|
||||
}).toString()).catch(() => ({ error: 'Could not load CAPTCHA' }));
|
||||
const question = $(`#${operation}-captcha-question`);
|
||||
if (result.error || !result.enabled) {
|
||||
state.captcha.internal[operation] = null;
|
||||
if (question) question.textContent = 'UNAVAILABLE';
|
||||
return;
|
||||
}
|
||||
state.captcha.internal[operation] = result.token;
|
||||
if (question) question.textContent = result.question;
|
||||
const answer = $(`#${operation}-captcha-answer`);
|
||||
if (answer) answer.value = '';
|
||||
}
|
||||
function loadGoogleCaptcha() {
|
||||
const captcha = state.config.security?.captcha || {};
|
||||
if (!captcha.google_enabled || !captcha.google_site_key) return Promise.resolve();
|
||||
if (window.grecaptcha) return Promise.resolve();
|
||||
if (state.captcha.scriptPromise) return state.captcha.scriptPromise;
|
||||
state.captcha.scriptPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
const render = captcha.google_version === 'v3'
|
||||
? encodeURIComponent(captcha.google_site_key) : 'explicit';
|
||||
script.src = `https://www.google.com/recaptcha/api.js?render=${render}`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error('Google reCAPTCHA could not load'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return state.captcha.scriptPromise;
|
||||
}
|
||||
async function renderGoogleCaptcha(operation) {
|
||||
const captcha = state.config.security?.captcha || {};
|
||||
if (!authCaptchaEnabled(operation) || !captcha.google_enabled
|
||||
|| captcha.google_version !== 'v2' || !captcha.google_site_key) return;
|
||||
await loadGoogleCaptcha();
|
||||
const target = $(`#${operation}-google-captcha`);
|
||||
if (!target || state.captcha.widgets[operation] != null) return;
|
||||
state.captcha.widgets[operation] = window.grecaptcha.render(target, {
|
||||
sitekey: captcha.google_site_key,
|
||||
theme: document.body.classList.contains('cc-light') ? 'light' : 'dark',
|
||||
});
|
||||
}
|
||||
async function setupAuthCaptcha() {
|
||||
['register', 'login'].forEach(operation => {
|
||||
$(`#${operation}-captcha-refresh`)?.addEventListener('click', () => loadInternalCaptcha(operation));
|
||||
});
|
||||
const activeOperation = $('[data-form].active')?.dataset.form;
|
||||
if (activeOperation === 'register' || activeOperation === 'login') {
|
||||
void loadInternalCaptcha(activeOperation);
|
||||
}
|
||||
const captcha = state.config.security?.captcha || {};
|
||||
if (!captcha.google_enabled || !captcha.google_site_key) return;
|
||||
try {
|
||||
await loadGoogleCaptcha();
|
||||
const active = $('[data-form].active')?.dataset.form;
|
||||
if (active === 'register' || active === 'login') await renderGoogleCaptcha(active);
|
||||
} catch (error) {
|
||||
authError(error.message);
|
||||
}
|
||||
}
|
||||
async function authForm(operation, values) {
|
||||
const body = new FormData();
|
||||
Object.entries(values).forEach(([key, value]) => body.append(key, value));
|
||||
const captcha = state.config.security?.captcha || {};
|
||||
if (!authCaptchaEnabled(operation)) return { method: 'POST', body };
|
||||
if (captcha.honeypot_enabled) {
|
||||
body.append('_hp_website', $(`#${operation}-hp-website`)?.value || '');
|
||||
body.append('_hp_contact', $(`#${operation}-hp-contact`)?.checked ? '1' : '0');
|
||||
}
|
||||
if (captcha.internal_enabled) {
|
||||
const token = state.captcha.internal[operation];
|
||||
const answer = $(`#${operation}-captcha-answer`)?.value.trim() || '';
|
||||
if (!token || !answer) throw new Error('Complete the human check');
|
||||
body.append('captcha_internal_token', token);
|
||||
body.append('captcha_internal_answer', answer);
|
||||
}
|
||||
if (captcha.google_enabled) {
|
||||
if (!captcha.google_site_key || !window.grecaptcha) throw new Error('Google reCAPTCHA is unavailable');
|
||||
let token = '';
|
||||
if (captcha.google_version === 'v3') {
|
||||
token = await new Promise((resolve, reject) => {
|
||||
window.grecaptcha.ready(() => {
|
||||
window.grecaptcha.execute(captcha.google_site_key, { action: operation }).then(resolve, reject);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const widget = state.captcha.widgets[operation];
|
||||
if (widget == null) throw new Error('Google reCAPTCHA is still loading');
|
||||
token = window.grecaptcha.getResponse(widget);
|
||||
}
|
||||
if (!token) throw new Error('Complete the Google reCAPTCHA check');
|
||||
body.append('captcha_google_token', token);
|
||||
}
|
||||
return { method: 'POST', body };
|
||||
}
|
||||
function refreshAuthCaptcha(operation) {
|
||||
void loadInternalCaptcha(operation);
|
||||
const widget = state.captcha.widgets[operation];
|
||||
if (widget != null && window.grecaptcha) window.grecaptcha.reset(widget);
|
||||
}
|
||||
|
||||
function auth(tab) {
|
||||
stopPoll();
|
||||
state.captcha.internal = {};
|
||||
state.captcha.widgets = {};
|
||||
const maxUser = state.config.chat?.max_username_length || 12;
|
||||
const minPass = state.config.security?.min_password_length || 4;
|
||||
$('#cc-body').innerHTML = `<div class="cc-auth-wrap"><div class="cc-auth-panel">
|
||||
@@ -207,11 +344,13 @@
|
||||
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="reg-user" maxlength="${maxUser}"></div>
|
||||
<div class="cc-field"><label class="cc-field-label">PASSPHRASE (MIN ${minPass})</label><input class="cc-input" id="reg-pass" type="password"></div>
|
||||
<div class="cc-field"><label class="cc-field-label">CONFIRM</label><input class="cc-input" id="reg-pass2" type="password"></div>
|
||||
${authCaptchaMarkup('register')}
|
||||
<button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div>
|
||||
<div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login">
|
||||
<p class="cc-form-hint">Welcome back.</p>
|
||||
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="login-user"></div>
|
||||
<div class="cc-field"><label class="cc-field-label">PASSPHRASE</label><input class="cc-input" id="login-pass" type="password"></div>
|
||||
${authCaptchaMarkup('login')}
|
||||
<button class="cc-btn cc-btn-login" id="login">AUTHENTICATE</button></div>
|
||||
<div class="cc-auth-form" data-form="2fa"><p class="cc-form-hint">Enter the code sent to your verified email.</p>
|
||||
<div class="cc-field"><label class="cc-field-label">SIX-DIGIT CODE</label><input class="cc-input" id="two-code" maxlength="6" inputmode="numeric"></div>
|
||||
@@ -221,11 +360,16 @@
|
||||
$('#register').addEventListener('click', register);
|
||||
$('#login').addEventListener('click', login);
|
||||
if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
|
||||
void setupAuthCaptcha();
|
||||
}
|
||||
function switchAuth(name) {
|
||||
$$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === name));
|
||||
$$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name));
|
||||
authError('');
|
||||
if (name === 'register' || name === 'login') {
|
||||
if (!state.captcha.internal[name]) void loadInternalCaptcha(name);
|
||||
renderGoogleCaptcha(name).catch(error => authError(error.message));
|
||||
}
|
||||
}
|
||||
function authError(message) {
|
||||
const item = $('#auth-error');
|
||||
@@ -237,16 +381,29 @@
|
||||
const password = $('#reg-pass').value;
|
||||
if (!username || !password) return authError('Username and password are required');
|
||||
if (password !== $('#reg-pass2').value) return authError('Passphrases do not match');
|
||||
const result = await api('auth.php', form({ action: 'register', username, password }));
|
||||
if (result.error) return authError(result.error);
|
||||
let request;
|
||||
try { request = await authForm('register', { action: 'register', username, password }); }
|
||||
catch (error) { return authError(error.message); }
|
||||
const result = await api('auth.php', request);
|
||||
if (result.error) {
|
||||
refreshAuthCaptcha('register');
|
||||
return authError(result.error);
|
||||
}
|
||||
state.user = result.user;
|
||||
await enter();
|
||||
}
|
||||
async function login() {
|
||||
const result = await api('auth.php', form({
|
||||
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
|
||||
}));
|
||||
if (result.error) return authError(result.error);
|
||||
let request;
|
||||
try {
|
||||
request = await authForm('login', {
|
||||
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
|
||||
});
|
||||
} catch (error) { return authError(error.message); }
|
||||
const result = await api('auth.php', request);
|
||||
if (result.error) {
|
||||
refreshAuthCaptcha('login');
|
||||
return authError(result.error);
|
||||
}
|
||||
if (result.requires_2fa) {
|
||||
state.loginChallenge = result.challenge;
|
||||
switchAuth('2fa');
|
||||
@@ -306,20 +463,51 @@
|
||||
else accountView();
|
||||
}
|
||||
|
||||
function voiceBitrate() {
|
||||
return Math.max(48, Math.min(320, Number(feature('voice_bitrate_kbps', 64)) || 64));
|
||||
}
|
||||
function replyPreviewText(message) {
|
||||
if (!message) return '';
|
||||
return message.message_type === 'voice' ? '[Voice clip]' : String(message.message || '').slice(0, 120);
|
||||
}
|
||||
function setReply(message) {
|
||||
state.reply = {
|
||||
id: Number(message.id),
|
||||
username: message.username,
|
||||
message: replyPreviewText(message),
|
||||
depth: Number(message.reply_depth) || 0,
|
||||
};
|
||||
const bar = $('#reply-context');
|
||||
if (bar) {
|
||||
bar.hidden = false;
|
||||
$('#reply-context-user').textContent = state.reply.username;
|
||||
$('#reply-context-text').textContent = state.reply.message;
|
||||
}
|
||||
$('#message')?.focus();
|
||||
}
|
||||
function clearReply() {
|
||||
state.reply = null;
|
||||
const bar = $('#reply-context');
|
||||
if (bar) bar.hidden = true;
|
||||
}
|
||||
|
||||
function chat() {
|
||||
const max = state.config.chat?.max_message_length || 500;
|
||||
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
|
||||
state.reply = null;
|
||||
$('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left">
|
||||
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
|
||||
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div></div>
|
||||
<div class="cc-recording-indicator" id="recording" hidden></div>
|
||||
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
|
||||
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
|
||||
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s</span>
|
||||
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS</span>
|
||||
${feature('auto_voice_send') ? `<label class="cc-voice-autoplay"><input id="auto-send" type="checkbox" ${state.voice.autoSend ? 'checked' : ''}> AUTO SEND</label>` : ''}
|
||||
${feature('auto_voice_play') ? `<label class="cc-voice-autoplay"><input id="auto-play" type="checkbox" ${state.voice.autoPlay ? 'checked' : ''}> AUTO PLAY</label>` : ''}
|
||||
<div class="cc-voice-review" id="voice-review" hidden>${audioPlayer('', 'voice-preview')}
|
||||
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
|
||||
<div class="cc-reply-context" id="reply-context" hidden><span>REPLYING TO <b id="reply-context-user"></b></span>
|
||||
<span id="reply-context-text"></span><button id="reply-cancel" type="button">CANCEL</button></div>
|
||||
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
|
||||
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
|
||||
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
|
||||
@@ -327,6 +515,7 @@
|
||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
|
||||
});
|
||||
$('#send').addEventListener('click', sendText);
|
||||
$('#reply-cancel').addEventListener('click', clearReply);
|
||||
$('#record')?.addEventListener('click', toggleVoice);
|
||||
$('#voice-send')?.addEventListener('click', sendVoice);
|
||||
$('#voice-discard')?.addEventListener('click', discardVoice);
|
||||
@@ -343,11 +532,20 @@
|
||||
const input = $('#message');
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
const max = state.config.chat?.max_message_length || 500;
|
||||
input.value = '';
|
||||
const result = await api('messages.php', form({ action: 'send', message }))
|
||||
$('#count').textContent = max;
|
||||
const result = await api('messages.php', form({
|
||||
action: 'send', message, reply_to_id: state.reply?.id || ''
|
||||
}))
|
||||
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
||||
if (result.error) { input.value = message; return toast(result.error); }
|
||||
if (result.error) {
|
||||
input.value = message;
|
||||
$('#count').textContent = max - message.length;
|
||||
return toast(result.error);
|
||||
}
|
||||
acknowledgeSentMessage(result.message);
|
||||
clearReply();
|
||||
requestPoll();
|
||||
}
|
||||
function startPoll() {
|
||||
@@ -471,7 +669,14 @@
|
||||
function normalizeMessage(message) {
|
||||
const id = Number(message?.id);
|
||||
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||
return { ...message, id };
|
||||
const replyToId = Number(message?.reply_to_id);
|
||||
return {
|
||||
...message,
|
||||
id,
|
||||
reply_to_id: Number.isSafeInteger(replyToId) && replyToId > 0 ? replyToId : null,
|
||||
reply_depth: Math.max(0, Number(message?.reply_depth) || 0),
|
||||
voice_bitrate_kbps: Number(message?.voice_bitrate_kbps) || null,
|
||||
};
|
||||
}
|
||||
function acknowledgeSentMessage(message) {
|
||||
const acknowledged = normalizeMessage(message);
|
||||
@@ -489,7 +694,9 @@
|
||||
return JSON.stringify([
|
||||
message.user_id ?? null, message.username, message.color, message.message,
|
||||
message.message_type, message.voice_url, message.voice_mime,
|
||||
message.voice_duration, message.created_at
|
||||
message.voice_duration, message.voice_bitrate_kbps, message.reply_to_id,
|
||||
message.reply_depth, message.reply_username, message.reply_message,
|
||||
message.reply_message_type, message.created_at
|
||||
]);
|
||||
}
|
||||
function compareMessagesChronologically(a, b) {
|
||||
@@ -562,16 +769,39 @@
|
||||
}
|
||||
function createMessageElement(message) {
|
||||
const id = message.id;
|
||||
const depth = Math.max(0, Number(message.reply_depth) || 0);
|
||||
const maxDepth = Math.max(1, Number(state.config.chat?.reply_max_depth) || 4);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '') + ' cc-msg-new';
|
||||
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '')
|
||||
+ (depth ? ' cc-msg-thread' : '') + ' cc-msg-new';
|
||||
item.style.setProperty('--cc-reply-indent', `${Math.min(depth, 6) * 12}px`);
|
||||
item.dataset.id = String(id);
|
||||
item.dataset.renderKey = messageRenderKey(message);
|
||||
const content = message.message_type === 'voice' && message.voice_url
|
||||
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>`
|
||||
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps} KBPS` : ''}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>`
|
||||
: linkify(esc(message.message));
|
||||
const parentText = message.reply_message_type === 'voice'
|
||||
? '[Voice clip]'
|
||||
: (message.reply_message || 'Original message unavailable');
|
||||
const replyReference = message.reply_to_id
|
||||
? `<button class="cc-reply-ref" type="button" data-parent-id="${message.reply_to_id}">
|
||||
<b>${esc(message.reply_username || `#${message.reply_to_id}`)}</b>
|
||||
<span>${esc(String(parentText).slice(0, 120))}</span></button>`
|
||||
: '';
|
||||
const replyAction = state.config.chat?.replies_enabled !== false && depth < maxDepth
|
||||
? '<button class="cc-msg-reply" type="button">REPLY</button>' : '';
|
||||
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
||||
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
||||
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
||||
<span class="cc-msg-sep">></span><span class="cc-msg-text">${replyReference}${content}</span>${replyAction}`;
|
||||
$('.cc-msg-reply', item)?.addEventListener('click', () => setReply(message));
|
||||
$('.cc-reply-ref', item)?.addEventListener('click', event => {
|
||||
const parentId = Number(event.currentTarget.dataset.parentId);
|
||||
const parent = $(`.cc-msg[data-id="${parentId}"]`);
|
||||
if (!parent) return toast('Original message is outside the current view', 'info');
|
||||
parent.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
parent.classList.add('cc-msg-focus');
|
||||
setTimeout(() => parent.classList.remove('cc-msg-focus'), 1200);
|
||||
});
|
||||
bindAudioPlayers(item);
|
||||
return item;
|
||||
}
|
||||
@@ -644,15 +874,25 @@
|
||||
];
|
||||
let recorder = null;
|
||||
let mime = '';
|
||||
const audioBitsPerSecond = voiceBitrate() * 1000;
|
||||
for (const candidate of candidates) {
|
||||
if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue;
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, { mimeType: candidate });
|
||||
recorder = new MediaRecorder(stream, { mimeType: candidate, audioBitsPerSecond });
|
||||
mime = candidate;
|
||||
break;
|
||||
} catch {}
|
||||
} catch {
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, { mimeType: candidate });
|
||||
mime = candidate;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!recorder) {
|
||||
try { recorder = new MediaRecorder(stream, { audioBitsPerSecond }); }
|
||||
catch { recorder = new MediaRecorder(stream); }
|
||||
}
|
||||
if (!recorder) recorder = new MediaRecorder(stream);
|
||||
state.voice.stream = stream;
|
||||
state.voice.chunks = [];
|
||||
state.voice.started = Date.now();
|
||||
@@ -677,7 +917,9 @@
|
||||
function voiceTimer() {
|
||||
const max = state.config.voice?.max_duration_seconds || 60;
|
||||
const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max);
|
||||
if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`;
|
||||
if ($('#voice-status')) {
|
||||
$('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)} · ${voiceBitrate()} KBPS`;
|
||||
}
|
||||
if (elapsed >= max) stopVoice(false);
|
||||
}
|
||||
function stopVoice(discard) {
|
||||
@@ -706,7 +948,7 @@
|
||||
$('#voice-preview').src = state.voice.url;
|
||||
$('#voice-preview').load();
|
||||
$('#voice-review').hidden = false;
|
||||
$('#voice-status').textContent = 'READY ' + duration(length);
|
||||
$('#voice-status').textContent = `READY ${duration(length)} · ${voiceBitrate()} KBPS`;
|
||||
}
|
||||
function discardVoice() {
|
||||
if (state.voice.recorder) stopVoice(true);
|
||||
@@ -716,6 +958,9 @@
|
||||
state.voice.mime = '';
|
||||
state.voice.duration = 0;
|
||||
if ($('#voice-review')) $('#voice-review').hidden = true;
|
||||
if ($('#voice-status') && !state.voice.recorder) {
|
||||
$('#voice-status').textContent = `VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS`;
|
||||
}
|
||||
}
|
||||
async function sendVoice() {
|
||||
if (!state.voice.blob || state.voice.sending) return;
|
||||
@@ -723,6 +968,7 @@
|
||||
const body = new FormData();
|
||||
body.append('action', 'send_voice');
|
||||
body.append('duration', state.voice.duration.toFixed(2));
|
||||
body.append('reply_to_id', state.reply?.id || '');
|
||||
const uploadName = state.voice.mime.includes('mp4') ? 'voice.m4a'
|
||||
: state.voice.mime.includes('aac') ? 'voice.aac'
|
||||
: state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm';
|
||||
@@ -733,6 +979,7 @@
|
||||
if (result.error) return toast(result.error);
|
||||
acknowledgeSentMessage(result.message);
|
||||
discardVoice();
|
||||
clearReply();
|
||||
requestPoll();
|
||||
}
|
||||
|
||||
@@ -753,8 +1000,13 @@
|
||||
function archiveRow(message) {
|
||||
const content = message.message_type === 'voice' && message.voice_url
|
||||
? audioPlayer(publicUrl(message.voice_url), '', message.voice_mime) : esc(message.message);
|
||||
const reply = message.reply_to_id
|
||||
? `<div class="cc-archive-reply">Reply to ${esc(message.reply_username || `#${message.reply_to_id}`)}:
|
||||
${esc(message.reply_message_type === 'voice' ? '[Voice clip]' : (message.reply_message || 'Original unavailable'))}</div>`
|
||||
: '';
|
||||
return `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
|
||||
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
|
||||
<span class="cc-type-tag">${esc(message.message_type)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps}k` : ''}</span>
|
||||
<div>${reply}${content}</div></div>`;
|
||||
}
|
||||
|
||||
async function accountView() {
|
||||
|
||||
Reference in New Issue
Block a user