From eedad846eff380ee0b2cc6191b855091c507138c Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Tue, 17 Feb 2026 21:49:33 +0000 Subject: [PATCH] Implement delay timing --- core/clients/signal.py | 4 +- core/clients/signalapi.py | 8 +- core/clients/whatsapp.py | 17 +- core/clients/xmpp.py | 46 +++- core/templates/partials/compose-panel.html | 265 ++++++++++++++++++++- core/views/compose.py | 4 +- 6 files changed, 310 insertions(+), 34 deletions(-) diff --git a/core/clients/signal.py b/core/clients/signal.py index 09d6f84..2555389 100644 --- a/core/clients/signal.py +++ b/core/clients/signal.py @@ -42,7 +42,9 @@ def _is_internal_compose_blob_url(value: str) -> bool: def _is_compose_blob_only_text(text_value: str) -> bool: - lines = [line.strip() for line in str(text_value or "").splitlines() if line.strip()] + lines = [ + line.strip() for line in str(text_value or "").splitlines() if line.strip() + ] if not lines: return False return all(_is_internal_compose_blob_url(line) for line in lines) diff --git a/core/clients/signalapi.py b/core/clients/signalapi.py index 991f9a1..98b65d9 100644 --- a/core/clients/signalapi.py +++ b/core/clients/signalapi.py @@ -48,7 +48,9 @@ async def download_and_encode_base64(file_url, filename, content_type, session=N return None file_data = await response.read() base64_encoded = base64.b64encode(file_data).decode("utf-8") - return f"data:{content_type};filename={filename};base64,{base64_encoded}" + return ( + f"data:{content_type};filename={filename};base64,{base64_encoded}" + ) async with aiohttp.ClientSession() as local_session: async with local_session.get(file_url, timeout=10) as response: @@ -104,7 +106,9 @@ async def send_message_raw(recipient_uuid, text=None, attachments=None): file_url = row.get("url") if not file_url: return None - return await download_and_encode_base64(file_url, filename, content_type, session) + return await download_and_encode_base64( + file_url, filename, content_type, session + ) # Asynchronously resolve and encode all attachments attachments = attachments or [] diff --git a/core/clients/whatsapp.py b/core/clients/whatsapp.py index 9306e90..7d70a2d 100644 --- a/core/clients/whatsapp.py +++ b/core/clients/whatsapp.py @@ -2168,9 +2168,8 @@ class WhatsAppClient(ClientBase): if not isinstance(payload, (bytes, bytearray)): return [] - filename = ( - self._pluck(msg_obj, "documentMessage", "fileName") - or self._pluck(msg_obj, "document_message", "file_name") + filename = self._pluck(msg_obj, "documentMessage", "fileName") or self._pluck( + msg_obj, "document_message", "file_name" ) content_type = ( self._pluck(msg_obj, "documentMessage", "mimetype") @@ -2184,7 +2183,9 @@ class WhatsAppClient(ClientBase): or self._infer_media_content_type(msg_obj) ) if not filename: - ext = mimetypes.guess_extension(str(content_type or "").split(";", 1)[0].strip().lower()) + ext = mimetypes.guess_extension( + str(content_type or "").split(";", 1)[0].strip().lower() + ) filename = f"wa-{int(time.time())}{ext or '.bin'}" blob_key = media_bridge.put_blob( service="whatsapp", @@ -2749,7 +2750,9 @@ class WhatsAppClient(ClientBase): "whatsapp media send ok: method=%s filename=%s ts=%s", send_method, filename, - self._normalize_timestamp(self._pluck(response, "Timestamp") or 0), + self._normalize_timestamp( + self._pluck(response, "Timestamp") or 0 + ), ) except Exception as exc: self.log.warning("whatsapp attachment send failed: %s", exc) @@ -2984,7 +2987,9 @@ class WhatsAppClient(ClientBase): ] for args in attempts: try: - response = await self._call_client_method(method, *args, timeout=9.0) + response = await self._call_client_method( + method, *args, timeout=9.0 + ) if response is not None: self.log.debug( "reaction-bridge whatsapp-send ok method=%s args_len=%s", diff --git a/core/clients/xmpp.py b/core/clients/xmpp.py index 9db7e48..d8d6132 100644 --- a/core/clients/xmpp.py +++ b/core/clients/xmpp.py @@ -1188,7 +1188,9 @@ class XMPPComponent(ComponentXMPP): recipient_service, identifier.identifier, emoji=str(reaction_payload.get("emoji") or ""), - target_message_id=str((bridge or {}).get("upstream_message_id") or ""), + target_message_id=str( + (bridge or {}).get("upstream_message_id") or "" + ), target_timestamp=int((bridge or {}).get("upstream_ts") or 0), target_author=str((bridge or {}).get("upstream_author") or ""), remove=bool(reaction_payload.get("remove")), @@ -1542,7 +1544,9 @@ class XMPPComponent(ComponentXMPP): service=person_identifier.service, xmpp_message_id=xmpp_id, xmpp_ts=int(time.time() * 1000), - upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""), + upstream_message_id=str( + (source_ref or {}).get("upstream_message_id") or "" + ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), text_preview=str(text or ""), @@ -1553,9 +1557,13 @@ class XMPPComponent(ComponentXMPP): identifier=person_identifier, source_service=person_identifier.service, local_message_id=str((source_ref or {}).get("legacy_message_id") or ""), - local_ts=int((source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)), + local_ts=int( + (source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000) + ), xmpp_message_id=xmpp_id, - upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""), + upstream_message_id=str( + (source_ref or {}).get("upstream_message_id") or "" + ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), ) @@ -1569,7 +1577,9 @@ class XMPPComponent(ComponentXMPP): service=person_identifier.service, xmpp_message_id=xmpp_id, xmpp_ts=int(time.time() * 1000), - upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""), + upstream_message_id=str( + (source_ref or {}).get("upstream_message_id") or "" + ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), text_preview=str(text or ""), @@ -1580,9 +1590,13 @@ class XMPPComponent(ComponentXMPP): identifier=person_identifier, source_service=person_identifier.service, local_message_id=str((source_ref or {}).get("legacy_message_id") or ""), - local_ts=int((source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)), + local_ts=int( + (source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000) + ), xmpp_message_id=xmpp_id, - upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""), + upstream_message_id=str( + (source_ref or {}).get("upstream_message_id") or "" + ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), ) @@ -1611,7 +1625,9 @@ class XMPPComponent(ComponentXMPP): service=person_identifier.service, xmpp_message_id=str(row.get("xmpp_message_id") or "").strip(), xmpp_ts=int(time.time() * 1000), - upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""), + upstream_message_id=str( + (source_ref or {}).get("upstream_message_id") or "" + ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), text_preview=str(row.get("url") or text or ""), @@ -1622,13 +1638,21 @@ class XMPPComponent(ComponentXMPP): identifier=person_identifier, source_service=person_identifier.service, local_message_id=str((source_ref or {}).get("legacy_message_id") or ""), - local_ts=int((source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)), + local_ts=int( + (source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000) + ), xmpp_message_id=str(row.get("xmpp_message_id") or "").strip(), - upstream_message_id=str((source_ref or {}).get("upstream_message_id") or ""), + upstream_message_id=str( + (source_ref or {}).get("upstream_message_id") or "" + ), upstream_author=str((source_ref or {}).get("upstream_author") or ""), upstream_ts=int((source_ref or {}).get("upstream_ts") or 0), ) - return [str(row.get("url") or "").strip() for row in normalized_rows if str(row.get("url") or "").strip()] + return [ + str(row.get("url") or "").strip() + for row in normalized_rows + if str(row.get("url") or "").strip() + ] class XMPPClient(ClientBase): diff --git a/core/templates/partials/compose-panel.html b/core/templates/partials/compose-panel.html index 273f5ba..534e9ee 100644 --- a/core/templates/partials/compose-panel.html +++ b/core/templates/partials/compose-panel.html @@ -247,7 +247,7 @@ {% with gap=msg.gap_fragments.0 %}

+ title="{{ gap.focus|default:'Opponent delay between turns.' }} · Latency {{ gap.lag|default:'-' }}{% if gap.calculation %} · How it is calculated: {{ gap.calculation }}{% endif %}{% if gap.psychology %} · Psychological interpretation: {{ gap.psychology }}{% endif %}"> {{ gap.lag|default:"-" }}

@@ -827,6 +827,32 @@ color: inherit; text-decoration: none; } + #{{ panel_id }} .compose-glance-item.is-equal-size { + width: 10.6rem; + max-width: 10.6rem; + justify-content: space-between; + } + #{{ panel_id }} .compose-glance-item.compose-glance-item-reply { + gap: 0.22rem; + } + #{{ panel_id }} .compose-reply-mini-track { + width: 2.35rem; + height: 0.22rem; + border-radius: 999px; + background: rgba(33, 44, 61, 0.15); + overflow: hidden; + flex: 0 0 auto; + } + #{{ panel_id }} .compose-reply-mini-fill { + display: block; + height: 100%; + width: 0%; + border-radius: 999px; + background: rgba(46, 125, 50, 0.92); + } + #{{ panel_id }} .compose-glance-item.compose-glance-item-reply.is-over-target .compose-reply-mini-fill { + background: rgba(194, 37, 37, 0.92); + } #{{ panel_id }} a.compose-glance-item:hover { border-color: rgba(35, 84, 175, 0.45); background: rgba(234, 243, 255, 0.96); @@ -1290,6 +1316,9 @@ if (previousState && previousState.timer) { clearInterval(previousState.timer); } + if (previousState && previousState.replyTimingTimer) { + clearInterval(previousState.replyTimingTimer); + } if (previousState && previousState.eventHandler) { document.body.removeEventListener("composeMessageSent", previousState.eventHandler); } @@ -1316,6 +1345,7 @@ lightboxImages: [], lightboxIndex: -1, seenMessageIds: new Set(), + replyTimingTimer: null, }; window.giaComposePanels[panelId] = panelState; const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger")); @@ -1362,6 +1392,113 @@ } return String(Math.floor(ts / 60000)); }; + + const formatElapsedCompact = function (msValue) { + const totalSeconds = Math.max(0, Math.floor((toInt(msValue) || 0) / 1000)); + if (totalSeconds < 60) { + return String(totalSeconds) + "s"; + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) { + return String(minutes) + "m " + String(seconds) + "s"; + } + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + if (hours < 24) { + return String(hours) + "h " + String(remMinutes) + "m"; + } + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return String(days) + "d " + String(remHours) + "h"; + }; + + const collectReplyTimingSnapshot = function () { + const rows = Array.from(thread.querySelectorAll(".compose-row")); + if (!rows.length) { + return null; + } + const timeline = rows + .map(function (row, idx) { + return { + ts: toInt(row && row.dataset ? row.dataset.ts : 0), + outgoing: !!(row && row.classList && row.classList.contains("is-out")), + order: idx, + }; + }) + .filter(function (item) { + return item.ts > 0; + }) + .sort(function (left, right) { + if (left.ts === right.ts) { + return left.order - right.order; + } + return left.ts - right.ts; + }); + if (!timeline.length) { + return null; + } + let counterpartBaselineMs = null; + for (let idx = timeline.length - 1; idx > 0; idx -= 1) { + const current = timeline[idx]; + const previous = timeline[idx - 1]; + if (!current.outgoing && previous.outgoing && current.ts >= previous.ts) { + counterpartBaselineMs = current.ts - previous.ts; + break; + } + } + const last = timeline[timeline.length - 1]; + return { + lastTs: last.ts, + isMyTurn: !last.outgoing, + counterpartBaselineMs: counterpartBaselineMs, + }; + }; + + const updateReplyTimingUi = function () { + const snapshot = collectReplyTimingSnapshot(); + if (!snapshot || !snapshot.lastTs) { + replyTimingState = { + sinceLabel: "-", + targetLabel: "-", + percent: 0, + isOverTarget: false, + }; + renderReplyTimingChip(); + return; + } + + const elapsedMs = Math.max(0, Date.now() - snapshot.lastTs); + const sinceLabel = formatElapsedCompact(elapsedMs); + + const baselineFromGapMs = toInt( + glanceState && glanceState.gap ? glanceState.gap.lag_ms : 0 + ); + const baselineMs = baselineFromGapMs > 0 + ? baselineFromGapMs + : toInt(snapshot.counterpartBaselineMs); + if (!baselineMs) { + replyTimingState = { + sinceLabel: sinceLabel, + targetLabel: "pending", + percent: 0, + isOverTarget: false, + }; + renderReplyTimingChip(); + return; + } + + const ratio = elapsedMs / baselineMs; + const percent = Math.max(0, Math.round(ratio * 100)); + replyTimingState = { + sinceLabel: sinceLabel, + targetLabel: formatElapsedCompact(baselineMs), + percent: percent, + isOverTarget: ratio > 1, + }; + renderReplyTimingChip(); + ensurePriorityGlanceOrder(); + }; const collectLightboxImages = function () { return Array.from(thread.querySelectorAll(".compose-image")); }; @@ -1451,6 +1588,12 @@ gap: null, metrics: [], }; + let replyTimingState = { + sinceLabel: "-", + targetLabel: "-", + percent: 0, + isOverTarget: false, + }; const insightUrlForMetric = function (metricSlug) { const slug = String(metricSlug || "").trim(); const personId = String(thread.dataset.person || "").trim(); @@ -1699,22 +1842,36 @@ return; } const safe = Array.isArray(items) ? items.slice(0, 3) : []; + const ordered = safe + .filter(function (item) { + return /^delay$/i.test(String(item && item.label ? item.label : "")); + }) + .concat( + safe.filter(function (item) { + return !/^delay$/i.test(String(item && item.label ? item.label : "")); + }) + ); glanceNode.innerHTML = ""; - if (!safe.length) { - glanceNode.classList.add("is-hidden"); - return; - } - safe.forEach(function (item) { + ordered.forEach(function (item) { const url = String(item.url || "").trim(); + const label = String(item.label || "Info"); + const isStabilityConfidence = /stability\s+confidence/i.test(label); + const isDelayLabel = /^delay$/i.test(label) || /^opponent\s+delay$/i.test(label); const chip = document.createElement(url ? "a" : "span"); chip.className = "compose-glance-item"; + if (isStabilityConfidence) { + chip.classList.add("is-stability-confidence", "is-equal-size"); + } + if (isDelayLabel) { + chip.classList.add("is-delay"); + } chip.title = String(item.tooltip || ""); if (url) { chip.href = url; } const key = document.createElement("span"); key.className = "compose-glance-key"; - key.textContent = String(item.label || "Info"); + key.textContent = label; const val = document.createElement("span"); val.className = "compose-glance-val"; val.textContent = String(item.value || "-"); @@ -1722,7 +1879,84 @@ chip.appendChild(val); glanceNode.appendChild(chip); }); - glanceNode.classList.remove("is-hidden"); + + renderReplyTimingChip(); + ensurePriorityGlanceOrder(); + + if (glanceNode.children.length) { + glanceNode.classList.remove("is-hidden"); + } else { + glanceNode.classList.add("is-hidden"); + } + }; + + const ensurePriorityGlanceOrder = function () { + if (!glanceNode) { + return; + } + const children = Array.from(glanceNode.querySelectorAll(".compose-glance-item")); + const delayChip = children.find(function (chip) { + if (chip.classList.contains("is-delay")) { + return true; + } + const key = chip.querySelector(".compose-glance-key"); + return /^delay$/i.test(String(key ? key.textContent : "").trim()); + }) || null; + const replyChip = glanceNode.querySelector(".compose-glance-item-reply"); + + if (delayChip) { + glanceNode.insertBefore(delayChip, glanceNode.firstChild); + } + if (replyChip && delayChip) { + glanceNode.insertBefore(replyChip, delayChip.nextSibling); + } else if (replyChip) { + glanceNode.insertBefore(replyChip, glanceNode.firstChild); + } + }; + + const renderReplyTimingChip = function () { + if (!glanceNode) { + return; + } + let chip = glanceNode.querySelector(".compose-glance-item-reply"); + if (!chip) { + chip = document.createElement("span"); + chip.className = "compose-glance-item compose-glance-item-reply is-equal-size"; + + const key = document.createElement("span"); + key.className = "compose-glance-key"; + key.textContent = "Elapsed"; + chip.appendChild(key); + + const value = document.createElement("span"); + value.className = "compose-glance-val"; + value.dataset.role = "reply-value"; + value.textContent = "-"; + chip.appendChild(value); + + const track = document.createElement("span"); + track.className = "compose-reply-mini-track"; + const fill = document.createElement("span"); + fill.className = "compose-reply-mini-fill"; + fill.dataset.role = "reply-fill"; + track.appendChild(fill); + chip.appendChild(track); + + glanceNode.appendChild(chip); + } + + const valueNode = chip.querySelector('[data-role="reply-value"]'); + const fillNode = chip.querySelector('[data-role="reply-fill"]'); + if (valueNode) { + valueNode.textContent = String(replyTimingState.sinceLabel || "-") + " · " + String(replyTimingState.percent || 0) + "%"; + } + if (fillNode) { + fillNode.style.width = String(Math.max(0, Math.min(100, toInt(replyTimingState.percent)))) + "%"; + } + chip.title = "Last message: " + String(replyTimingState.sinceLabel || "-") + + " | Target: " + String(replyTimingState.targetLabel || "-") + + " | Progress: " + String(replyTimingState.percent || 0) + "%"; + chip.classList.toggle("is-over-target", !!replyTimingState.isOverTarget); }; const updateGlanceFromState = function () { @@ -1732,10 +1966,10 @@ glanceState.gap.slug || "inbound_response_score" ); items.push({ - label: "Response Delay", + label: "Delay", value: String(glanceState.gap.lag || "-") + " · " + String(glanceState.gap.score || "-"), tooltip: [ - String(glanceState.gap.focus || "Response delay"), + String(glanceState.gap.focus || "Delay"), "Delay " + String(glanceState.gap.lag || "-"), "Score " + String(glanceState.gap.score || "-"), glanceState.gap.calculation ? ("How it is calculated: " + String(glanceState.gap.calculation || "")) : "", @@ -1780,10 +2014,10 @@ const latencyTooltip = function (gap) { if (!gap || typeof gap !== "object") { - return "Response delay between turns."; + return "Delay between turns."; } return [ - String(gap.focus || "Response delay between turns."), + String(gap.focus || "Delay between turns."), "Latency " + String(gap.lag || "-"), gap.calculation ? ("How it is calculated: " + String(gap.calculation || "")) : "", gap.psychology ? ("Psychological interpretation: " + String(gap.psychology || "")) : "", @@ -2106,6 +2340,7 @@ applyMinuteGrouping(); scrollToBottom(shouldStick); } + updateReplyTimingUi(); }; const applyTyping = function (typingPayload) { @@ -2386,6 +2621,7 @@ panelState.seenMessageIds = new Set(); glanceState = { gap: null, metrics: [] }; renderGlanceItems([]); + updateReplyTimingUi(); poll(true); }; @@ -3339,11 +3575,16 @@ document.body.addEventListener("composeSendResult", panelState.sendResultHandler); hydrateBodyUrlsAsImages(thread); + updateReplyTimingUi(); + panelState.replyTimingTimer = setInterval(updateReplyTimingUi, 1000); scrollToBottom(true); setupWebSocket(); panelState.timer = setInterval(function () { if (!document.getElementById(panelId)) { clearInterval(panelState.timer); + if (panelState.replyTimingTimer) { + clearInterval(panelState.replyTimingTimer); + } document.body.removeEventListener("composeMessageSent", panelState.eventHandler); document.body.removeEventListener("composeSendResult", panelState.sendResultHandler); document.removeEventListener("mousedown", panelState.docClickHandler); diff --git a/core/views/compose.py b/core/views/compose.py index c96e8d1..9c892d9 100644 --- a/core/views/compose.py +++ b/core/views/compose.py @@ -828,7 +828,7 @@ def _glance_items_from_state(gap_fragment=None, metric_fragments=None, person_id items = [] if gap_fragment: tooltip_parts = [ - f"{gap_fragment.get('focus') or 'Response delay'}", + f"{gap_fragment.get('focus') or 'Delay'}", f"Delay {gap_fragment.get('lag') or '-'}", f"Score {gap_fragment.get('score') or '-'}", ] @@ -842,7 +842,7 @@ def _glance_items_from_state(gap_fragment=None, metric_fragments=None, person_id ) items.append( { - "label": "Response Delay", + "label": "Delay", "value": f"{gap_fragment.get('lag') or '-'} · {gap_fragment.get('score') or '-'}", "tooltip": " | ".join(tooltip_parts), "url": _insight_detail_url(