mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
feat(chat): add quote dialog with configurable quote length
Add Group Chat tab in Settings with configurable quote byte limit. When quoting a message longer than the limit, a dialog asks whether to use full or truncated quote (with editable byte count). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,10 @@ DM_RETRY_DEFAULTS = {
|
||||
'grace_period': 60, # seconds to wait for late ACKs after exhaustion
|
||||
}
|
||||
|
||||
CHAT_SETTINGS_DEFAULTS = {
|
||||
'quote_max_bytes': 20, # max UTF-8 bytes for truncated quote
|
||||
}
|
||||
|
||||
|
||||
def get_dm_retry_settings() -> dict:
|
||||
"""Get DM retry settings from database."""
|
||||
@@ -293,6 +297,28 @@ def save_dm_retry_settings(settings: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_chat_settings() -> dict:
|
||||
"""Get chat settings from database."""
|
||||
db = _get_db()
|
||||
if db:
|
||||
saved = db.get_setting_json('chat_settings', {})
|
||||
return {**CHAT_SETTINGS_DEFAULTS, **saved}
|
||||
return dict(CHAT_SETTINGS_DEFAULTS)
|
||||
|
||||
|
||||
def save_chat_settings(settings: dict) -> bool:
|
||||
"""Save chat settings to database."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return False
|
||||
try:
|
||||
db.set_setting_json('chat_settings', settings)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save chat settings: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@api_bp.route('/messages', methods=['GET'])
|
||||
def get_messages():
|
||||
"""
|
||||
@@ -2142,6 +2168,42 @@ def set_auto_retry_config():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/chat/settings', methods=['GET'])
|
||||
def get_chat_config():
|
||||
"""Get chat settings."""
|
||||
try:
|
||||
return jsonify(get_chat_settings()), 200
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/chat/settings', methods=['POST'])
|
||||
def set_chat_config():
|
||||
"""Update chat settings."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'Missing JSON body'}), 400
|
||||
|
||||
valid_keys = set(CHAT_SETTINGS_DEFAULTS.keys())
|
||||
settings = {}
|
||||
for key in valid_keys:
|
||||
if key in data:
|
||||
val = data[key]
|
||||
if not isinstance(val, (int, float)) or val < 1:
|
||||
return jsonify({'success': False, 'error': f'Invalid value for {key}'}), 400
|
||||
settings[key] = int(val)
|
||||
|
||||
if not settings:
|
||||
return jsonify({'success': False, 'error': 'No valid settings provided'}), 400
|
||||
|
||||
if save_chat_settings(settings):
|
||||
return jsonify({**get_chat_settings(), 'success': True}), 200
|
||||
return jsonify({'success': False, 'error': 'Failed to save settings'}), 500
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/dm/updates', methods=['GET'])
|
||||
def get_dm_updates():
|
||||
"""
|
||||
|
||||
@@ -1292,37 +1292,78 @@ function replyTo(username) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a user's message
|
||||
* Truncate text to maxBytes UTF-8 bytes, respecting multi-byte characters.
|
||||
* @returns {string} truncated text (without "..." suffix)
|
||||
*/
|
||||
function truncateToBytes(text, maxBytes) {
|
||||
const encoder = new TextEncoder();
|
||||
if (encoder.encode(text).length <= maxBytes) return text;
|
||||
let truncated = '';
|
||||
let byteCount = 0;
|
||||
for (const char of text) {
|
||||
const charBytes = encoder.encode(char).length;
|
||||
if (byteCount + charBytes > maxBytes) break;
|
||||
truncated += char;
|
||||
byteCount += charBytes;
|
||||
}
|
||||
return truncated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a quote into the message input.
|
||||
*/
|
||||
function insertQuote(username, quotedText) {
|
||||
const input = document.getElementById('messageInput');
|
||||
input.value = `@[${username}] »${quotedText}« `;
|
||||
updateCharCounter();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a user's message — shows a dialog to choose full or truncated quote.
|
||||
* @param {string} username - Username to mention
|
||||
* @param {string} content - Original message content to quote
|
||||
*/
|
||||
function quoteTo(username, content) {
|
||||
const input = document.getElementById('messageInput');
|
||||
const maxQuoteBytes = 20;
|
||||
|
||||
// Calculate UTF-8 byte length
|
||||
const encoder = new TextEncoder();
|
||||
const contentBytes = encoder.encode(content);
|
||||
const contentBytes = encoder.encode(content).length;
|
||||
const maxBytes = chatSettingsCache.quote_max_bytes || CHAT_SETTINGS_DEFAULTS.quote_max_bytes;
|
||||
|
||||
let quotedText;
|
||||
if (contentBytes.length <= maxQuoteBytes) {
|
||||
quotedText = content;
|
||||
} else {
|
||||
// Truncate to ~maxQuoteBytes, being careful with multi-byte characters
|
||||
let truncated = '';
|
||||
let byteCount = 0;
|
||||
for (const char of content) {
|
||||
const charBytes = encoder.encode(char).length;
|
||||
if (byteCount + charBytes > maxQuoteBytes) break;
|
||||
truncated += char;
|
||||
byteCount += charBytes;
|
||||
}
|
||||
quotedText = truncated + '...';
|
||||
// If message fits within limit, insert directly — no dialog needed
|
||||
if (contentBytes <= maxBytes) {
|
||||
insertQuote(username, content);
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = `@[${username}] »${quotedText}« `;
|
||||
updateCharCounter();
|
||||
input.focus();
|
||||
// Show quote dialog
|
||||
const preview = truncateToBytes(content, 60);
|
||||
document.getElementById('quotePreview').textContent =
|
||||
preview.length < content.length ? preview + '...' : preview;
|
||||
document.getElementById('quoteBytesInput').value = maxBytes;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('quoteModal'));
|
||||
|
||||
// Clean up old listeners by replacing buttons
|
||||
const fullBtn = document.getElementById('quoteFullBtn');
|
||||
const truncBtn = document.getElementById('quoteTruncatedBtn');
|
||||
const newFullBtn = fullBtn.cloneNode(true);
|
||||
const newTruncBtn = truncBtn.cloneNode(true);
|
||||
fullBtn.parentNode.replaceChild(newFullBtn, fullBtn);
|
||||
truncBtn.parentNode.replaceChild(newTruncBtn, truncBtn);
|
||||
|
||||
newFullBtn.addEventListener('click', () => {
|
||||
modal.hide();
|
||||
insertQuote(username, content);
|
||||
});
|
||||
|
||||
newTruncBtn.addEventListener('click', () => {
|
||||
modal.hide();
|
||||
const customBytes = parseInt(document.getElementById('quoteBytesInput').value, 10) || maxBytes;
|
||||
const truncated = truncateToBytes(content, customBytes);
|
||||
insertQuote(username, truncated + '...');
|
||||
});
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1641,6 +1682,71 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Settings Modal
|
||||
// =============================================================================
|
||||
|
||||
// --- Chat Settings ---
|
||||
|
||||
const CHAT_SETTINGS_DEFAULTS = {
|
||||
quote_max_bytes: 20
|
||||
};
|
||||
|
||||
const CHAT_SETTINGS_FIELDS = {
|
||||
quote_max_bytes: 'settQuoteMaxBytes'
|
||||
};
|
||||
|
||||
let chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS };
|
||||
|
||||
function populateChatSettingsForm(data) {
|
||||
for (const [key, elId] of Object.entries(CHAT_SETTINGS_FIELDS)) {
|
||||
const el = document.getElementById(elId);
|
||||
if (el) el.value = data[key] ?? CHAT_SETTINGS_DEFAULTS[key];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChatSettings() {
|
||||
try {
|
||||
const resp = await fetch('/api/chat/settings');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS, ...data };
|
||||
populateChatSettingsForm(chatSettingsCache);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load chat settings:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChatSettings() {
|
||||
const payload = {};
|
||||
for (const [key, elId] of Object.entries(CHAT_SETTINGS_FIELDS)) {
|
||||
const el = document.getElementById(elId);
|
||||
const val = parseInt(el.value, 10);
|
||||
if (isNaN(val) || val < parseInt(el.min) || val > parseInt(el.max)) {
|
||||
showNotification(`Invalid value for ${el.previousElementSibling?.textContent || key}`, 'danger');
|
||||
el.focus();
|
||||
return;
|
||||
}
|
||||
payload[key] = val;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/chat/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS, ...data };
|
||||
showNotification('Settings saved', 'success');
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
showNotification(err.error || 'Failed to save', 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Failed to save settings', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// --- DM Retry Settings ---
|
||||
|
||||
const DM_RETRY_DEFAULTS = {
|
||||
direct_max_retries: 3,
|
||||
direct_flood_retries: 1,
|
||||
@@ -1710,7 +1816,10 @@ async function saveDmRetrySettings() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('show.bs.modal', loadDmRetrySettings);
|
||||
settingsModal.addEventListener('show.bs.modal', () => {
|
||||
loadDmRetrySettings();
|
||||
loadChatSettings();
|
||||
});
|
||||
settingsModal.addEventListener('shown.bs.modal', () => {
|
||||
settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
bootstrap.Tooltip.getOrCreateInstance(el);
|
||||
@@ -1729,6 +1838,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('settingsResetBtn')?.addEventListener('click', () => {
|
||||
populateDmRetryForm(DM_RETRY_DEFAULTS);
|
||||
});
|
||||
|
||||
const chatSettingsForm = document.getElementById('chatSettingsForm');
|
||||
if (chatSettingsForm) {
|
||||
chatSettingsForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
saveChatSettings();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('chatSettingsResetBtn')?.addEventListener('click', () => {
|
||||
populateChatSettingsForm(CHAT_SETTINGS_DEFAULTS);
|
||||
});
|
||||
|
||||
// Load chat settings cache on startup (for quote dialog)
|
||||
loadChatSettings();
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -347,7 +347,9 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabSettingsMessages" type="button">Messages</button>
|
||||
</li>
|
||||
<!-- Future tabs added here -->
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsChat" type="button">Group Chat</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tabSettingsMessages">
|
||||
@@ -403,12 +405,52 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tabSettingsChat">
|
||||
<form id="chatSettingsForm">
|
||||
<h6 class="text-muted mb-2">Quote</h6>
|
||||
<table class="table table-sm table-borderless mb-3 align-middle">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-0">Quote length (bytes) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Default max UTF-8 bytes for truncated quotes"><i class="bi bi-info-circle"></i></span></td>
|
||||
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settQuoteMaxBytes" min="5" max="120" value="20"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="chatSettingsResetBtn">Reset to defaults</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote Dialog Modal -->
|
||||
<div class="modal fade" id="quoteModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-quote"></i> Quote message</h6>
|
||||
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body py-2">
|
||||
<p class="text-muted small mb-2" id="quotePreview"></p>
|
||||
<div class="d-flex gap-2 align-items-center mb-2">
|
||||
<label class="form-label mb-0 small text-nowrap" for="quoteBytesInput">Max bytes:</label>
|
||||
<input type="number" class="form-control form-control-sm" id="quoteBytesInput" min="5" max="120" style="width:5rem">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-1">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="quoteTruncatedBtn">Truncated</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="quoteFullBtn">Full quote</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Modal (Leaflet) -->
|
||||
<div class="modal fade" id="mapModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
|
||||
Reference in New Issue
Block a user