feat(inbox): email height fits content + per-message reply button

- Email messages now size to their own content (fitEmailFrame): iframe sandbox gains
  allow-same-origin (NO allow-scripts — scripts stay blocked, parent just measures
  body.scrollHeight) and the height is set on @load + re-measured on expand. Removes
  the fixed 300px frame and the "fill empty space" stretch (no more big whitespace).
  msg-body is now v-if (renders on expand) so the measure runs while visible + lazier.
- Reply arrow in each CUSTOMER message header → opens + focuses the composer without
  scrolling to the bottom button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-15 07:29:34 -04:00
parent 08069ec0dc
commit f519bb3dd6

View File

@ -327,27 +327,28 @@
<input ref="cannedFileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="display:none" @change="onCannedFile" />
<div ref="messagesEl" class="conv-messages">
<div v-for="msg in activeDiscussion.messages" :key="msg.id" class="conv-msg" :class="['conv-msg-' + msg.from, { 'msg-fill': props.inline && msg.html && msg.id === lastMessageId && isExpanded(msg) }]">
<div v-for="msg in activeDiscussion.messages" :key="msg.id" class="conv-msg" :class="'conv-msg-' + msg.from">
<!-- En-tête repliable (accordéon, style Gmail) : qui · heure · aperçu clic pour déplier/replier -->
<div class="msg-head" :class="{ 'msg-head-open': isExpanded(msg) }" @click="toggleMsg(msg)">
<div class="msg-head-line1 row items-center no-wrap">
<q-icon :name="msgIcon(msg)" :color="msg.from === 'agent' ? (msg.via === 'ai' ? 'deep-purple-4' : 'indigo-5') : 'teal-6'" size="14px" class="q-mr-xs" />
<span class="msg-who">{{ msgWho(msg) }}</span>
<q-space />
<q-btn v-if="msg.from === 'customer' && activeDiscussion.status === 'active'" flat dense round size="xs" icon="reply" color="indigo-5" class="q-mr-xs" @click.stop="openReply()"><q-tooltip>Répondre à ce message</q-tooltip></q-btn>
<span class="msg-time q-ml-xs">{{ formatTime(msg.ts) }}</span>
<q-icon :name="isExpanded(msg) ? 'expand_less' : 'expand_more'" size="16px" color="grey-5" class="q-ml-xs" />
</div>
<!-- Aperçu DIRECTEMENT sous le nom (style Gmail), pas repoussé à droite -->
<div v-if="!isExpanded(msg)" class="msg-snippet">{{ msgSnippet(msg) }}</div>
</div>
<div v-show="isExpanded(msg)" class="msg-body">
<div v-if="isExpanded(msg)" class="msg-body">
<!-- Courriel complet (HTML) carte large + iframe sandbox (rendu WYSIWYG fidèle, scripts désactivés) -->
<div v-if="msg.html" class="email-card" :class="{ 'email-card-agent': msg.from === 'agent' }">
<div class="email-card-head">
<q-space />
<q-btn flat dense round size="xs" icon="open_in_full" color="grey-6" @click="expandEmail(msg)"><q-tooltip>Agrandir le courriel</q-tooltip></q-btn>
</div>
<iframe class="email-frame" :srcdoc="emailSrcdoc(msg.html)" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" loading="lazy"></iframe>
<iframe class="email-frame" :srcdoc="emailSrcdoc(msg.html)" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" loading="lazy" @load="fitEmailFrame"></iframe>
</div>
<!-- Bulle standard (SMS / chat / texte) -->
<div v-else class="conv-bubble">
@ -993,6 +994,16 @@ const presence = computed(() => {
const replyOpen = ref(false)
const composeEl = ref(null)
function openReply () { replyOpen.value = true; if (coordToken.value) notifyTyping(coordToken.value); nextTick(() => { try { composeEl.value?.querySelector('textarea, [contenteditable=true]')?.focus() } catch (e) { /* */ } }) }
// Ajuste la hauteur de l'iframe courriel à SON contenu (plus de 300px fixe / blancs). Sûr : sandbox `allow-same-origin` SANS `allow-scripts` le parent lit le DOM pour mesurer, mais aucun script du courriel ne s'exécute.
// Ajuste un iframe courriel à SON contenu (fini le 300px fixe / les blancs). Sûr : sandbox `allow-same-origin` SANS `allow-scripts` le parent lit le DOM pour mesurer, aucun script du courriel ne s'exécute.
function fitFrame (f) {
if (!f) return
const measure = () => { try { const h = f.contentDocument.body.scrollHeight; if (h > 4) f.style.height = Math.min(Math.max(h + 8, 48), 1800) + 'px' } catch (err) { f.style.height = '300px' } }
measure(); setTimeout(measure, 80) // @load (ou un déplié) peut précéder la mise en page 2e mesure après stabilisation
}
function fitEmailFrame (e) { fitFrame(e.target) }
// Re-mesure les courriels VISIBLES (le @load se produit pendant que le message est replié/display:none hauteur 0 ; il faut re-mesurer au dépliage).
function refitFrames () { nextTick(() => { const root = messagesEl.value; if (root) root.querySelectorAll('.email-frame').forEach(f => { if (f.offsetParent !== null) fitFrame(f) }) }) }
// Résumé IA du fil (« Summarise this email » de Gmail) pour l'agent, ne modifie rien
const summaryText = ref('')
const summaryBusy = ref(false)
@ -1058,13 +1069,13 @@ async function removeAssistant (email) { if (!coordToken.value) return; try { aw
// Accordéon des messages (style Gmail) : ancien replié, dernier déplié, nouvel entrant déplié.
const expandedMsgs = ref(new Set())
function isExpanded (m) { return expandedMsgs.value.has(m.id) }
function toggleMsg (m) { const s = new Set(expandedMsgs.value); s.has(m.id) ? s.delete(m.id) : s.add(m.id); expandedMsgs.value = s }
function toggleMsg (m) { const s = new Set(expandedMsgs.value); s.has(m.id) ? s.delete(m.id) : s.add(m.id); expandedMsgs.value = s; refitFrames() }
function msgWho (m) { return m.from === 'agent' ? (m.via === 'ai' ? 'IA' : 'Nous') : (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client') }
function msgIcon (m) { return m.html ? 'mail' : m.via === 'sms' ? 'sms' : m.via === 'ai' ? 'smart_toy' : m.media ? 'image' : 'chat' }
function msgSnippet (m) { const t = String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/\s+/g, ' ').trim(); return t.slice(0, 64) || (m.media ? '[image]' : m.html ? '[courriel]' : '') }
function cleanBody (t) { return String(t || '').replace(/^✉️[^\n]*\n+/, '') } // retire l'ancien préfixe sujet collé au corps (double en-tête)
const lastMessageId = computed(() => { const ms = activeDiscussion.value?.messages || []; return ms.length ? ms[ms.length - 1].id : null })
function expandLast () { const ms = activeDiscussion.value?.messages || []; expandedMsgs.value = new Set(ms.length ? [ms[ms.length - 1].id] : []) }
function expandLast () { const ms = activeDiscussion.value?.messages || []; expandedMsgs.value = new Set(ms.length ? [ms[ms.length - 1].id] : []); refitFrames() }
watch(coordToken, () => { svcStatus.value = null; expandLast(); replyOpen.value = false; summaryText.value = ''; summaryErr.value = ''; fetchAgents().then(a => { agents.value = a }) })
watch(() => (activeDiscussion.value?.messages || []).length, (n, o) => { if (n > (o || 0)) { const ms = activeDiscussion.value?.messages || []; if (ms.length) { const s = new Set(expandedMsgs.value); s.add(ms[ms.length - 1].id); expandedMsgs.value = s } } })
const gmailReplyUrl = computed(() => activeConv.value?.gmailReplyUrl || activeDiscussion.value?.gmailReplyUrl || null)
@ -1434,7 +1445,7 @@ function formatDate (dateStr) {
.email-card-agent { border-color: #c7d2fe; background: #f5f7ff; }
.email-card-head { display: flex; align-items: center; padding: 2px 0; background: transparent; }
.email-card-agent .email-card-head { background: #eef2ff; }
.email-frame { width: 100%; height: 300px; border: 0; background: #fff; display: block; }
.email-frame { width: 100%; height: auto; min-height: 48px; border: 0; background: #fff; display: block; }
.pay-card { margin: 6px 8px; padding: 8px 10px; border: 1px solid #bbf7d0; background: #f0fdf4; border-radius: 8px; }
.pay-card-no { border-color: #e2e8f0; background: #f8fafc; }
.email-editor { border: 1px solid #e2e8f0; border-radius: 8px; }
@ -1534,10 +1545,6 @@ function formatDate (dateStr) {
/* Plein écran : colonne flex qui remplit la hauteur ; le fil défile, le composeur reste en bas */
.conv-fullpage { display: flex; flex-direction: column; width: 100%; background: #fff; min-height: 0; }
.conv-fullpage .conv-messages { flex: 1 1 auto; overflow-y: auto; height: auto; min-height: 0; }
/* Le DERNIER message (déplié) s'étire pour occuper l'espace libre — le courriel remplit, le composeur reste accessible en bas */
.conv-fullpage .conv-msg.msg-fill { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 280px; }
.conv-fullpage .msg-fill .msg-body { flex: 1 1 auto; display: flex; min-height: 0; }
.conv-fullpage .msg-fill .email-card { flex: 1 1 auto; display: flex; flex-direction: column; }
.conv-fullpage .msg-fill .email-frame { flex: 1 1 auto; height: auto; }
/* Chaque courriel s'ajuste à SON contenu (fitEmailFrame) — plus de hauteur fixe ni d'étirement du dernier message. */
.conv-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; height: 50vh; color: #94a3b8; font-size: 0.9rem; }
</style>