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:
MarekWo
2026-03-21 15:25:38 +01:00
parent 33a71bed17
commit ce8227247f
3 changed files with 253 additions and 25 deletions

View File

@@ -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():
"""

View File

@@ -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();
});
/**

View File

@@ -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">