Allow sharing conversations
This commit is contained in:
125
core/commands/delivery.py
Normal file
125
core/commands/delivery.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
|
from core.clients import transport
|
||||||
|
from core.models import ChatSession, Message
|
||||||
|
|
||||||
|
STATUS_VISIBLE_SOURCE_SERVICES = {"web", "xmpp"}
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_for_transport(text: str, limit: int = 3000) -> list[str]:
|
||||||
|
body = str(text or "").strip()
|
||||||
|
if not body:
|
||||||
|
return []
|
||||||
|
if len(body) <= limit:
|
||||||
|
return [body]
|
||||||
|
parts = []
|
||||||
|
remaining = body
|
||||||
|
while len(remaining) > limit:
|
||||||
|
cut = remaining.rfind("\n\n", 0, limit)
|
||||||
|
if cut < int(limit * 0.45):
|
||||||
|
cut = remaining.rfind("\n", 0, limit)
|
||||||
|
if cut < int(limit * 0.35):
|
||||||
|
cut = limit
|
||||||
|
parts.append(remaining[:cut].rstrip())
|
||||||
|
remaining = remaining[cut:].lstrip()
|
||||||
|
if remaining:
|
||||||
|
parts.append(remaining)
|
||||||
|
return [part for part in parts if part]
|
||||||
|
|
||||||
|
|
||||||
|
async def post_status_in_source(trigger_message: Message, text: str, origin_tag: str) -> bool:
|
||||||
|
service = str(trigger_message.source_service or "").strip().lower()
|
||||||
|
if service not in STATUS_VISIBLE_SOURCE_SERVICES:
|
||||||
|
return False
|
||||||
|
if service == "web":
|
||||||
|
await sync_to_async(Message.objects.create)(
|
||||||
|
user=trigger_message.user,
|
||||||
|
session=trigger_message.session,
|
||||||
|
sender_uuid="",
|
||||||
|
text=text,
|
||||||
|
ts=int(time.time() * 1000),
|
||||||
|
custom_author="BOT",
|
||||||
|
source_service="web",
|
||||||
|
source_chat_id=trigger_message.source_chat_id or "",
|
||||||
|
message_meta={"origin_tag": origin_tag},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
# For non-web, route through transport raw API.
|
||||||
|
if not str(trigger_message.source_chat_id or "").strip():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await transport.send_message_raw(
|
||||||
|
service,
|
||||||
|
str(trigger_message.source_chat_id or "").strip(),
|
||||||
|
text=text,
|
||||||
|
attachments=[],
|
||||||
|
metadata={"origin_tag": origin_tag},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def post_to_channel_binding(
|
||||||
|
trigger_message: Message,
|
||||||
|
binding_service: str,
|
||||||
|
binding_channel_identifier: str,
|
||||||
|
text: str,
|
||||||
|
origin_tag: str,
|
||||||
|
command_slug: str,
|
||||||
|
) -> bool:
|
||||||
|
service = str(binding_service or "").strip().lower()
|
||||||
|
channel_identifier = str(binding_channel_identifier or "").strip()
|
||||||
|
if service == "web":
|
||||||
|
session = None
|
||||||
|
if channel_identifier and channel_identifier == str(
|
||||||
|
trigger_message.source_chat_id or ""
|
||||||
|
).strip():
|
||||||
|
session = trigger_message.session
|
||||||
|
if session is None and channel_identifier:
|
||||||
|
session = await sync_to_async(
|
||||||
|
lambda: ChatSession.objects.filter(
|
||||||
|
user=trigger_message.user,
|
||||||
|
identifier__identifier=channel_identifier,
|
||||||
|
)
|
||||||
|
.order_by("-last_interaction")
|
||||||
|
.first()
|
||||||
|
)()
|
||||||
|
if session is None:
|
||||||
|
session = trigger_message.session
|
||||||
|
await sync_to_async(Message.objects.create)(
|
||||||
|
user=trigger_message.user,
|
||||||
|
session=session,
|
||||||
|
sender_uuid="",
|
||||||
|
text=text,
|
||||||
|
ts=int(time.time() * 1000),
|
||||||
|
custom_author="BOT",
|
||||||
|
source_service="web",
|
||||||
|
source_chat_id=channel_identifier or str(trigger_message.source_chat_id or ""),
|
||||||
|
message_meta={"origin_tag": origin_tag},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
chunks = chunk_for_transport(text, limit=3000)
|
||||||
|
if not chunks:
|
||||||
|
return False
|
||||||
|
for chunk in chunks:
|
||||||
|
ts = await transport.send_message_raw(
|
||||||
|
service,
|
||||||
|
channel_identifier,
|
||||||
|
text=chunk,
|
||||||
|
attachments=[],
|
||||||
|
metadata={
|
||||||
|
"origin_tag": origin_tag,
|
||||||
|
"command_slug": command_slug,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not ts:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
@@ -5,15 +5,14 @@ import time
|
|||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from core.clients import transport
|
|
||||||
from core.commands.base import CommandContext, CommandHandler, CommandResult
|
from core.commands.base import CommandContext, CommandHandler, CommandResult
|
||||||
|
from core.commands.delivery import post_status_in_source, post_to_channel_binding
|
||||||
from core.messaging import ai as ai_runner
|
from core.messaging import ai as ai_runner
|
||||||
from core.messaging.utils import messages_to_string
|
from core.messaging.utils import messages_to_string
|
||||||
from core.models import (
|
from core.models import (
|
||||||
AI,
|
AI,
|
||||||
BusinessPlanDocument,
|
BusinessPlanDocument,
|
||||||
BusinessPlanRevision,
|
BusinessPlanRevision,
|
||||||
ChatSession,
|
|
||||||
CommandAction,
|
CommandAction,
|
||||||
CommandChannelBinding,
|
CommandChannelBinding,
|
||||||
CommandRun,
|
CommandRun,
|
||||||
@@ -60,56 +59,9 @@ def _bp_fallback_markdown(template_text: str, transcript: str, error_text: str =
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _chunk_for_transport(text: str, limit: int = 3000) -> list[str]:
|
|
||||||
body = str(text or "").strip()
|
|
||||||
if not body:
|
|
||||||
return []
|
|
||||||
if len(body) <= limit:
|
|
||||||
return [body]
|
|
||||||
parts = []
|
|
||||||
remaining = body
|
|
||||||
while len(remaining) > limit:
|
|
||||||
cut = remaining.rfind("\n\n", 0, limit)
|
|
||||||
if cut < int(limit * 0.45):
|
|
||||||
cut = remaining.rfind("\n", 0, limit)
|
|
||||||
if cut < int(limit * 0.35):
|
|
||||||
cut = limit
|
|
||||||
parts.append(remaining[:cut].rstrip())
|
|
||||||
remaining = remaining[cut:].lstrip()
|
|
||||||
if remaining:
|
|
||||||
parts.append(remaining)
|
|
||||||
return [part for part in parts if part]
|
|
||||||
|
|
||||||
|
|
||||||
class BPCommandHandler(CommandHandler):
|
class BPCommandHandler(CommandHandler):
|
||||||
slug = "bp"
|
slug = "bp"
|
||||||
|
|
||||||
async def _status_message(self, trigger_message: Message, text: str):
|
|
||||||
service = str(trigger_message.source_service or "").strip().lower()
|
|
||||||
if service == "web":
|
|
||||||
await sync_to_async(Message.objects.create)(
|
|
||||||
user=trigger_message.user,
|
|
||||||
session=trigger_message.session,
|
|
||||||
sender_uuid="",
|
|
||||||
text=text,
|
|
||||||
ts=int(time.time() * 1000),
|
|
||||||
custom_author="BOT",
|
|
||||||
source_service="web",
|
|
||||||
source_chat_id=trigger_message.source_chat_id or "",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if service == "xmpp" and str(trigger_message.source_chat_id or "").strip():
|
|
||||||
try:
|
|
||||||
await transport.send_message_raw(
|
|
||||||
"xmpp",
|
|
||||||
str(trigger_message.source_chat_id or "").strip(),
|
|
||||||
text=text,
|
|
||||||
attachments=[],
|
|
||||||
metadata={"origin_tag": f"bp-status:{trigger_message.id}"},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _fanout(self, run: CommandRun, text: str) -> dict:
|
async def _fanout(self, run: CommandRun, text: str) -> dict:
|
||||||
profile = run.profile
|
profile = run.profile
|
||||||
trigger = await sync_to_async(
|
trigger = await sync_to_async(
|
||||||
@@ -129,63 +81,17 @@ class BPCommandHandler(CommandHandler):
|
|||||||
sent_bindings = 0
|
sent_bindings = 0
|
||||||
failed_bindings = 0
|
failed_bindings = 0
|
||||||
for binding in bindings:
|
for binding in bindings:
|
||||||
if binding.service == "web":
|
ok = await post_to_channel_binding(
|
||||||
session = None
|
trigger_message=trigger,
|
||||||
channel_identifier = str(binding.channel_identifier or "").strip()
|
binding_service=binding.service,
|
||||||
if (
|
binding_channel_identifier=binding.channel_identifier,
|
||||||
channel_identifier
|
text=text,
|
||||||
and channel_identifier == str(trigger.source_chat_id or "").strip()
|
origin_tag=f"bp:{run.id}",
|
||||||
):
|
command_slug=self.slug,
|
||||||
session = trigger.session
|
)
|
||||||
if session is None and channel_identifier:
|
if ok:
|
||||||
session = await sync_to_async(
|
|
||||||
lambda: ChatSession.objects.filter(
|
|
||||||
user=trigger.user,
|
|
||||||
identifier__identifier=channel_identifier,
|
|
||||||
)
|
|
||||||
.order_by("-last_interaction")
|
|
||||||
.first()
|
|
||||||
)()
|
|
||||||
if session is None:
|
|
||||||
session = trigger.session
|
|
||||||
await sync_to_async(Message.objects.create)(
|
|
||||||
user=trigger.user,
|
|
||||||
session=session,
|
|
||||||
sender_uuid="",
|
|
||||||
text=text,
|
|
||||||
ts=int(time.time() * 1000),
|
|
||||||
custom_author="BOT",
|
|
||||||
source_service="web",
|
|
||||||
source_chat_id=channel_identifier or str(trigger.source_chat_id or ""),
|
|
||||||
message_meta={"origin_tag": f"bp:{run.id}"},
|
|
||||||
)
|
|
||||||
sent_bindings += 1
|
sent_bindings += 1
|
||||||
continue
|
else:
|
||||||
try:
|
|
||||||
chunks = _chunk_for_transport(text, limit=3000)
|
|
||||||
if not chunks:
|
|
||||||
failed_bindings += 1
|
|
||||||
continue
|
|
||||||
ok = True
|
|
||||||
for chunk in chunks:
|
|
||||||
ts = await transport.send_message_raw(
|
|
||||||
binding.service,
|
|
||||||
binding.channel_identifier,
|
|
||||||
text=chunk,
|
|
||||||
attachments=[],
|
|
||||||
metadata={
|
|
||||||
"origin_tag": f"bp:{run.id}",
|
|
||||||
"command_slug": "bp",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not ts:
|
|
||||||
ok = False
|
|
||||||
break
|
|
||||||
if ok:
|
|
||||||
sent_bindings += 1
|
|
||||||
else:
|
|
||||||
failed_bindings += 1
|
|
||||||
except Exception:
|
|
||||||
failed_bindings += 1
|
failed_bindings += 1
|
||||||
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
|
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
|
||||||
|
|
||||||
@@ -357,7 +263,11 @@ class BPCommandHandler(CommandHandler):
|
|||||||
status_text += f" · fanout sent:{sent_count}"
|
status_text += f" · fanout sent:{sent_count}"
|
||||||
if failed_count:
|
if failed_count:
|
||||||
status_text += f" failed:{failed_count}"
|
status_text += f" failed:{failed_count}"
|
||||||
await self._status_message(trigger, status_text)
|
await post_status_in_source(
|
||||||
|
trigger_message=trigger,
|
||||||
|
text=status_text,
|
||||||
|
origin_tag=f"bp-status:{trigger.id}",
|
||||||
|
)
|
||||||
|
|
||||||
run.status = "ok"
|
run.status = "ok"
|
||||||
run.result_ref = document
|
run.result_ref = document
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>GIA - {{ request.path_info }}</title>
|
<title>{% block browser_title %}{{ request.resolver_match.url_name|default:request.path_info|cut:"_"|cut:"/"|cut:"-"|upper|slice:":3" }}{% endblock %}</title>
|
||||||
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
|
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
|
||||||
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
|
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}">
|
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}">
|
||||||
|
|||||||
@@ -97,6 +97,10 @@
|
|||||||
<a class="compose-command-settings-link" href="{% url 'command_routing' %}">Open command routing</a>
|
<a class="compose-command-settings-link" href="{% url 'command_routing' %}">Open command routing</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="button is-light is-rounded compose-export-toggle" aria-expanded="false">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-layer-group"></i></span>
|
||||||
|
<span>Select/Export</span>
|
||||||
|
</button>
|
||||||
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
<span>Drafts</span>
|
<span>Drafts</span>
|
||||||
@@ -252,6 +256,75 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="{{ panel_id }}-export" class="compose-export is-hidden">
|
||||||
|
<div class="compose-export-head">
|
||||||
|
<p class="compose-export-title">Conversation Range Export</p>
|
||||||
|
<span id="{{ panel_id }}-export-summary" class="compose-export-summary">Select two messages to define a range.</span>
|
||||||
|
</div>
|
||||||
|
<div class="compose-export-controls">
|
||||||
|
<div class="select is-small">
|
||||||
|
<select id="{{ panel_id }}-export-scope">
|
||||||
|
<option value="inside">Inside Range</option>
|
||||||
|
<option value="outside">Outside Range</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="select is-small">
|
||||||
|
<select id="{{ panel_id }}-export-format">
|
||||||
|
<option value="plain">Plain Text</option>
|
||||||
|
<option value="markdown">Markdown</option>
|
||||||
|
<option value="share">Share Statements</option>
|
||||||
|
<option value="jsonl">JSONL</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<details class="compose-export-voice">
|
||||||
|
<summary>Speaker Labels</summary>
|
||||||
|
<div class="compose-export-voice-grid">
|
||||||
|
<div class="compose-export-voice-col">
|
||||||
|
<p class="compose-export-voice-title">Outgoing</p>
|
||||||
|
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="I said" checked> I said</label>
|
||||||
|
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="You said"> You said</label>
|
||||||
|
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="We said"> We said</label>
|
||||||
|
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="They said"> They said</label>
|
||||||
|
</div>
|
||||||
|
<div class="compose-export-voice-col">
|
||||||
|
<p class="compose-export-voice-title">Incoming</p>
|
||||||
|
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="They said" checked> They said</label>
|
||||||
|
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="You said"> You said</label>
|
||||||
|
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="I said"> I said</label>
|
||||||
|
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="We said"> We said</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="compose-export-fields">
|
||||||
|
<summary>Fields</summary>
|
||||||
|
<div class="compose-export-fields-grid">
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="text" checked> Text</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="time" checked> Time</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="sender" checked> Sender</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_service"> Source</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_label"> Source Label</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="direction"> Direction</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="message_id"> Message ID</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_message_id"> Source Message ID</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="ts"> Timestamp</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="button" id="{{ panel_id }}-export-copy" class="button is-small is-link is-light">Copy</button>
|
||||||
|
<button type="button" id="{{ panel_id }}-export-clear" class="button is-small is-light">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="compose-export-presets">
|
||||||
|
<span class="compose-export-help">Click one message for start, second for end. Third click starts a new range.</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="{{ panel_id }}-export-buffer"
|
||||||
|
class="textarea is-small compose-export-buffer"
|
||||||
|
readonly
|
||||||
|
spellcheck="false"
|
||||||
|
rows="10"
|
||||||
|
aria-label="Export conversation history"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="{{ panel_id }}-thread"
|
id="{{ panel_id }}-thread"
|
||||||
class="compose-thread"
|
class="compose-thread"
|
||||||
@@ -270,11 +343,13 @@
|
|||||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||||
{% for msg in serialized_messages %}
|
{% for msg in serialized_messages %}
|
||||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
|
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}" data-author="{{ msg.author|default:''|escape }}" data-display-ts="{{ msg.display_ts|escape }}" data-source-service="{{ msg.source_service|default:''|escape }}" data-source-label="{{ msg.source_label|default:''|escape }}" data-source-message-id="{{ msg.source_message_id|default:''|escape }}" data-direction="{% if msg.outgoing %}outgoing{% else %}incoming{% endif %}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
|
||||||
{% if msg.gap_fragments %}
|
{% if msg.gap_fragments %}
|
||||||
{% with gap=msg.gap_fragments.0 %}
|
{% with gap=msg.gap_fragments.0 %}
|
||||||
<p
|
<p
|
||||||
class="compose-latency-chip"
|
class="compose-latency-chip"
|
||||||
|
data-lag-ms="{{ gap.lag_ms|default:0 }}"
|
||||||
|
data-turn="{% if msg.outgoing %}outgoing{% else %}incoming{% endif %}"
|
||||||
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 %}">
|
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 %}">
|
||||||
<span class="icon is-small"><i class="fa-regular fa-clock"></i></span>
|
<span class="icon is-small"><i class="fa-regular fa-clock"></i></span>
|
||||||
<span class="compose-latency-val">{{ gap.lag|default:"-" }}</span>
|
<span class="compose-latency-val">{{ gap.lag|default:"-" }}</span>
|
||||||
@@ -507,17 +582,36 @@
|
|||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
background: rgba(247, 249, 252, 0.88);
|
background: rgba(247, 249, 252, 0.88);
|
||||||
border: 1px solid rgba(103, 121, 145, 0.16);
|
border: 1px solid rgba(103, 121, 145, 0.16);
|
||||||
|
position: relative;
|
||||||
|
--latency-pair-color: rgba(103, 121, 145, 0.45);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-latency-val {
|
#{{ panel_id }} .compose-latency-val {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #58667a;
|
color: #58667a;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-latency-chip::before,
|
#{{ panel_id }} .compose-latency-chip.is-pace-matched {
|
||||||
#{{ panel_id }} .compose-latency-chip::after {
|
--latency-pair-color: rgba(43, 133, 74, 0.85);
|
||||||
content: "";
|
border-color: rgba(43, 133, 74, 0.52);
|
||||||
width: 0.95rem;
|
background: rgba(234, 251, 240, 0.98);
|
||||||
height: 1px;
|
color: #236140;
|
||||||
background: rgba(101, 119, 141, 0.28);
|
}
|
||||||
|
#{{ panel_id }} .compose-latency-chip.is-pace-fast {
|
||||||
|
--latency-pair-color: rgba(39, 98, 189, 0.82);
|
||||||
|
border-color: rgba(39, 98, 189, 0.45);
|
||||||
|
background: rgba(234, 244, 255, 0.98);
|
||||||
|
color: #234d8f;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-latency-chip.is-pace-slow {
|
||||||
|
--latency-pair-color: rgba(191, 95, 36, 0.85);
|
||||||
|
border-color: rgba(191, 95, 36, 0.5);
|
||||||
|
background: rgba(255, 242, 232, 0.98);
|
||||||
|
color: #8e4620;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-latency-chip.is-pace-matched .compose-latency-val,
|
||||||
|
#{{ panel_id }} .compose-latency-chip.is-pace-fast .compose-latency-val,
|
||||||
|
#{{ panel_id }} .compose-latency-chip.is-pace-slow .compose-latency-val {
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-bubble {
|
#{{ panel_id }} .compose-bubble {
|
||||||
max-width: min(85%, 46rem);
|
max-width: min(85%, 46rem);
|
||||||
@@ -990,24 +1084,31 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
border: 1px solid rgba(47, 79, 122, 0.32);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.12rem 0.45rem;
|
padding: 0.14rem 0.5rem;
|
||||||
background: rgba(250, 252, 255, 0.95);
|
background: #f5f9ff;
|
||||||
font-size: 0.64rem;
|
font-size: 0.64rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-glance-item.is-equal-size {
|
#{{ panel_id }} .compose-glance-item.is-equal-size {
|
||||||
width: 10.6rem;
|
width: 12.8rem;
|
||||||
max-width: 10.6rem;
|
max-width: 12.8rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-glance-item.compose-glance-item-reply {
|
#{{ panel_id }} .compose-glance-item.compose-glance-item-reply {
|
||||||
gap: 0.22rem;
|
gap: 0.22rem;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-glance-item.compose-glance-item-reply .compose-glance-val {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-reply-mini-track {
|
#{{ panel_id }} .compose-reply-mini-track {
|
||||||
width: 2.35rem;
|
width: 2.35rem;
|
||||||
height: 0.22rem;
|
height: 0.22rem;
|
||||||
@@ -1034,6 +1135,11 @@
|
|||||||
color: #5b6a7c;
|
color: #5b6a7c;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-glance-key::after {
|
||||||
|
content: ":";
|
||||||
|
margin-left: 0.14rem;
|
||||||
|
color: #7a8ca3;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-glance-val {
|
#{{ panel_id }} .compose-glance-val {
|
||||||
color: #26384f;
|
color: #26384f;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -1041,6 +1147,116 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-range-anchor .compose-bubble {
|
||||||
|
border-color: rgba(35, 84, 175, 0.68);
|
||||||
|
box-shadow: 0 0 0 2px rgba(35, 84, 175, 0.12);
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-range-selected .compose-bubble {
|
||||||
|
border-color: rgba(53, 124, 77, 0.44);
|
||||||
|
background: linear-gradient(180deg, rgba(239, 253, 244, 0.95), rgba(255, 255, 255, 0.98));
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: linear-gradient(180deg, rgba(248, 252, 255, 0.9), rgba(255, 255, 255, 0.98));
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #233651;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-summary {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #5b6a7c;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-fields {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.18rem 0.34rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-voice {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.18rem 0.34rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-voice > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #2b3d56;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-voice-grid {
|
||||||
|
margin-top: 0.28rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.2rem 0.6rem;
|
||||||
|
min-width: min(20rem, 72vw);
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-voice-col {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.14rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-voice-title {
|
||||||
|
margin: 0 0 0.08rem 0;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
color: #5b6a7c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-fields > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #2b3d56;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-fields-grid {
|
||||||
|
margin-top: 0.28rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.24rem 0.5rem;
|
||||||
|
min-width: min(26rem, 72vw);
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-presets {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-help {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
color: #657283;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-export-buffer {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-status-line {
|
#{{ panel_id }} .compose-status-line {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
@@ -1487,6 +1703,17 @@
|
|||||||
const lightboxImage = document.getElementById(panelId + "-lightbox-image");
|
const lightboxImage = document.getElementById(panelId + "-lightbox-image");
|
||||||
const lightboxPrev = document.getElementById(panelId + "-lightbox-prev");
|
const lightboxPrev = document.getElementById(panelId + "-lightbox-prev");
|
||||||
const lightboxNext = document.getElementById(panelId + "-lightbox-next");
|
const lightboxNext = document.getElementById(panelId + "-lightbox-next");
|
||||||
|
const exportToggle = panel.querySelector(".compose-export-toggle");
|
||||||
|
const exportBox = document.getElementById(panelId + "-export");
|
||||||
|
const exportSummary = document.getElementById(panelId + "-export-summary");
|
||||||
|
const exportScope = document.getElementById(panelId + "-export-scope");
|
||||||
|
const exportFormat = document.getElementById(panelId + "-export-format");
|
||||||
|
const exportVoiceOut = Array.from(panel.querySelectorAll(".compose-export-voice-out"));
|
||||||
|
const exportVoiceIn = Array.from(panel.querySelectorAll(".compose-export-voice-in"));
|
||||||
|
const exportFieldChecks = Array.from(panel.querySelectorAll(".compose-export-field"));
|
||||||
|
const exportCopy = document.getElementById(panelId + "-export-copy");
|
||||||
|
const exportClear = document.getElementById(panelId + "-export-clear");
|
||||||
|
const exportBuffer = document.getElementById(panelId + "-export-buffer");
|
||||||
const csrfToken = "{{ csrf_token }}";
|
const csrfToken = "{{ csrf_token }}";
|
||||||
if (lightbox && lightbox.parentElement !== document.body) {
|
if (lightbox && lightbox.parentElement !== document.body) {
|
||||||
document.body.appendChild(lightbox);
|
document.body.appendChild(lightbox);
|
||||||
@@ -1528,6 +1755,9 @@
|
|||||||
seenMessageIds: new Set(),
|
seenMessageIds: new Set(),
|
||||||
replyTimingTimer: null,
|
replyTimingTimer: null,
|
||||||
replyTargetId: "",
|
replyTargetId: "",
|
||||||
|
rangeStartId: "",
|
||||||
|
rangeEndId: "",
|
||||||
|
rangeMode: "inside",
|
||||||
};
|
};
|
||||||
window.giaComposePanels[panelId] = panelState;
|
window.giaComposePanels[panelId] = panelState;
|
||||||
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
||||||
@@ -2187,6 +2417,79 @@
|
|||||||
chip.classList.toggle("is-over-target", !!replyTimingState.isOverTarget);
|
chip.classList.toggle("is-over-target", !!replyTimingState.isOverTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetLatencyPacingState = function (chip) {
|
||||||
|
if (!chip || !chip.classList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chip.classList.remove(
|
||||||
|
"is-pace-matched",
|
||||||
|
"is-pace-fast",
|
||||||
|
"is-pace-slow"
|
||||||
|
);
|
||||||
|
if (chip.dataset) {
|
||||||
|
chip.dataset.pacingNote = "";
|
||||||
|
}
|
||||||
|
if (chip.dataset && chip.dataset.baseTitle !== undefined) {
|
||||||
|
chip.title = chip.dataset.baseTitle || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLatencyPacingPairs = function () {
|
||||||
|
if (!thread) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chips = Array.from(thread.querySelectorAll(".compose-latency-chip"));
|
||||||
|
chips.forEach(function (chip) {
|
||||||
|
if (!chip.dataset.baseTitle) {
|
||||||
|
chip.dataset.baseTitle = String(chip.title || "");
|
||||||
|
}
|
||||||
|
resetLatencyPacingState(chip);
|
||||||
|
});
|
||||||
|
if (chips.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let index = 1; index < chips.length; index += 1) {
|
||||||
|
const prev = chips[index - 1];
|
||||||
|
const curr = chips[index];
|
||||||
|
const prevMs = toInt(prev.dataset.lagMs || 0);
|
||||||
|
const currMs = toInt(curr.dataset.lagMs || 0);
|
||||||
|
const prevTurn = String(prev.dataset.turn || "");
|
||||||
|
const currTurn = String(curr.dataset.turn || "");
|
||||||
|
if (!prevMs || !currMs || !prevTurn || !currTurn || prevTurn === currTurn) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ratio = currMs / prevMs;
|
||||||
|
const pct = Math.max(0, Math.round(ratio * 100));
|
||||||
|
let paceClass = "is-pace-matched";
|
||||||
|
let paceLabel = "Matched turn pacing";
|
||||||
|
if (pct < 80) {
|
||||||
|
paceClass = "is-pace-fast";
|
||||||
|
paceLabel = "Faster than the previous turn";
|
||||||
|
} else if (pct > 125) {
|
||||||
|
paceClass = "is-pace-slow";
|
||||||
|
paceLabel = "Slower than the previous turn";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!prev.classList.contains("is-pace-matched")
|
||||||
|
&& !prev.classList.contains("is-pace-fast")
|
||||||
|
&& !prev.classList.contains("is-pace-slow")
|
||||||
|
) {
|
||||||
|
prev.classList.add(paceClass);
|
||||||
|
}
|
||||||
|
curr.classList.add(paceClass);
|
||||||
|
if (!String(prev.dataset.pacingNote || "").trim()) {
|
||||||
|
prev.dataset.pacingNote = "Pacing: " + paceLabel;
|
||||||
|
}
|
||||||
|
prev.title = [String(prev.dataset.baseTitle || ""), String(prev.dataset.pacingNote || "")]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" | ");
|
||||||
|
curr.dataset.pacingNote = "Pacing: " + paceLabel;
|
||||||
|
curr.title = [String(curr.dataset.baseTitle || ""), "Pacing: " + paceLabel]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" | ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateGlanceFromState = function () {
|
const updateGlanceFromState = function () {
|
||||||
const items = [];
|
const items = [];
|
||||||
if (glanceState.gap) {
|
if (glanceState.gap) {
|
||||||
@@ -2218,6 +2521,20 @@
|
|||||||
url: insightUrlForMetric(metricSlug),
|
url: insightUrlForMetric(metricSlug),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (!glanceState.metrics || !glanceState.metrics.length) {
|
||||||
|
items.push({
|
||||||
|
label: "Stability Score",
|
||||||
|
value: "n/a",
|
||||||
|
tooltip: "No stability score available yet for this conversation.",
|
||||||
|
url: "",
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: "Stability Confidence",
|
||||||
|
value: "n/a",
|
||||||
|
tooltip: "No stability confidence available yet for this conversation.",
|
||||||
|
url: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
renderGlanceItems(items);
|
renderGlanceItems(items);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2263,6 +2580,8 @@
|
|||||||
}
|
}
|
||||||
const chip = document.createElement("p");
|
const chip = document.createElement("p");
|
||||||
chip.className = "compose-latency-chip";
|
chip.className = "compose-latency-chip";
|
||||||
|
chip.dataset.lagMs = String(gap.lag_ms || 0);
|
||||||
|
chip.dataset.turn = msg.outgoing ? "outgoing" : "incoming";
|
||||||
chip.title = latencyTooltip(gap);
|
chip.title = latencyTooltip(gap);
|
||||||
const icon = document.createElement("span");
|
const icon = document.createElement("span");
|
||||||
icon.className = "icon is-small";
|
icon.className = "icon is-small";
|
||||||
@@ -2317,6 +2636,12 @@
|
|||||||
row.dataset.replySnippet = normalizeSnippet(
|
row.dataset.replySnippet = normalizeSnippet(
|
||||||
msg.display_text || msg.text || (msg.image_url ? "" : "(no text)")
|
msg.display_text || msg.text || (msg.image_url ? "" : "(no text)")
|
||||||
);
|
);
|
||||||
|
row.dataset.author = String(msg.author || "");
|
||||||
|
row.dataset.displayTs = String(msg.display_ts || msg.ts || "");
|
||||||
|
row.dataset.sourceService = String(msg.source_service || "");
|
||||||
|
row.dataset.sourceLabel = String(msg.source_label || "");
|
||||||
|
row.dataset.sourceMessageId = String(msg.source_message_id || "");
|
||||||
|
row.dataset.direction = outgoing ? "outgoing" : "incoming";
|
||||||
if (msg.reply_to_id) {
|
if (msg.reply_to_id) {
|
||||||
row.dataset.replyToId = String(msg.reply_to_id || "");
|
row.dataset.replyToId = String(msg.reply_to_id || "");
|
||||||
}
|
}
|
||||||
@@ -2482,6 +2807,7 @@
|
|||||||
}
|
}
|
||||||
row.appendChild(bubble);
|
row.appendChild(bubble);
|
||||||
insertRowByTs(row);
|
insertRowByTs(row);
|
||||||
|
applyLatencyPacingPairs();
|
||||||
wireImageFallbacks(row);
|
wireImageFallbacks(row);
|
||||||
bindReplyReferences(row);
|
bindReplyReferences(row);
|
||||||
updateGlanceFromMessage(msg);
|
updateGlanceFromMessage(msg);
|
||||||
@@ -2556,6 +2882,16 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const rowClick = ev.target.closest && ev.target.closest(".compose-row[data-message-id]");
|
||||||
|
if (
|
||||||
|
rowClick
|
||||||
|
&& exportBox
|
||||||
|
&& !exportBox.classList.contains("is-hidden")
|
||||||
|
&& !ev.target.closest(".compose-reply-link")
|
||||||
|
) {
|
||||||
|
setRangeAnchor(rowClick.dataset.messageId || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
|
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
|
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
|
||||||
@@ -2631,6 +2967,7 @@
|
|||||||
scrollToBottom(shouldStick);
|
scrollToBottom(shouldStick);
|
||||||
}
|
}
|
||||||
updateReplyTimingUi();
|
updateReplyTimingUi();
|
||||||
|
updateExportBuffer();
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyTyping = function (typingPayload) {
|
const applyTyping = function (typingPayload) {
|
||||||
@@ -2866,6 +3203,344 @@
|
|||||||
return thread.querySelector('.compose-row[data-message-id="' + key + '"]');
|
return thread.querySelector('.compose-row[data-message-id="' + key + '"]');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allMessageRows = function () {
|
||||||
|
return Array.from(thread.querySelectorAll(".compose-row[data-message-id]"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRangeRows = function () {
|
||||||
|
const rows = allMessageRows();
|
||||||
|
if (!rows.length || !panelState.rangeStartId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const startIndex = rows.findIndex(function (row) {
|
||||||
|
return String(row.dataset.messageId || "") === String(panelState.rangeStartId || "");
|
||||||
|
});
|
||||||
|
if (startIndex < 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let endIndex = startIndex;
|
||||||
|
if (panelState.rangeEndId) {
|
||||||
|
const idx = rows.findIndex(function (row) {
|
||||||
|
return String(row.dataset.messageId || "") === String(panelState.rangeEndId || "");
|
||||||
|
});
|
||||||
|
if (idx >= 0) {
|
||||||
|
endIndex = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lo = Math.min(startIndex, endIndex);
|
||||||
|
const hi = Math.max(startIndex, endIndex);
|
||||||
|
if (String(panelState.rangeMode || "inside") === "outside") {
|
||||||
|
return rows.filter(function (_row, idx) {
|
||||||
|
return idx < lo || idx > hi;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows.filter(function (_row, idx) {
|
||||||
|
return idx >= lo && idx <= hi;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRangeSelectionUi = function () {
|
||||||
|
const selectedSet = new Set(
|
||||||
|
selectedRangeRows().map(function (row) {
|
||||||
|
return String(row.dataset.messageId || "");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
allMessageRows().forEach(function (row) {
|
||||||
|
const id = String(row.dataset.messageId || "");
|
||||||
|
row.classList.toggle("is-range-selected", selectedSet.has(id));
|
||||||
|
row.classList.toggle("is-range-anchor", id === String(panelState.rangeStartId || "") || id === String(panelState.rangeEndId || ""));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageRowsToExportRecords = function () {
|
||||||
|
return selectedRangeRows().map(function (row) {
|
||||||
|
const bodyNode = row.querySelector(".compose-body");
|
||||||
|
const text = String(bodyNode ? bodyNode.textContent || "" : "").trim();
|
||||||
|
const authorRaw = String(row.dataset.author || "").trim();
|
||||||
|
const author = authorRaw || (row.classList.contains("is-out") ? "USER" : "CONTACT");
|
||||||
|
const when = String(row.dataset.displayTs || "").trim();
|
||||||
|
return {
|
||||||
|
message_id: String(row.dataset.messageId || ""),
|
||||||
|
ts: toInt(row.dataset.ts || 0),
|
||||||
|
sender: author,
|
||||||
|
time: when,
|
||||||
|
direction: String(row.dataset.direction || (row.classList.contains("is-out") ? "outgoing" : "incoming")),
|
||||||
|
source_service: String(row.dataset.sourceService || "").trim(),
|
||||||
|
source_label: String(row.dataset.sourceLabel || "").trim(),
|
||||||
|
source_message_id: String(row.dataset.sourceMessageId || "").trim(),
|
||||||
|
text: text || "(no text)",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedExportFields = function () {
|
||||||
|
const defaults = ["text", "time", "sender"];
|
||||||
|
const picked = exportFieldChecks
|
||||||
|
.filter(function (node) { return !!(node && node.checked); })
|
||||||
|
.map(function (node) { return String(node.value || "").trim(); })
|
||||||
|
.filter(Boolean);
|
||||||
|
return picked.length ? picked : defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeFieldNode = function () {
|
||||||
|
return exportFieldChecks.find(function (node) {
|
||||||
|
return String(node.value || "").trim() === "time";
|
||||||
|
}) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncExportFieldLocks = function () {
|
||||||
|
const format = String(exportFormat && exportFormat.value ? exportFormat.value : "plain");
|
||||||
|
const timeNode = timeFieldNode();
|
||||||
|
if (!timeNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format === "share") {
|
||||||
|
timeNode.checked = true;
|
||||||
|
timeNode.disabled = true;
|
||||||
|
} else {
|
||||||
|
timeNode.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderedExportFields = function (fields) {
|
||||||
|
const seen = new Set();
|
||||||
|
const input = Array.isArray(fields) ? fields.slice() : [];
|
||||||
|
const normalized = input.filter(function (field) {
|
||||||
|
const key = String(field || "").trim();
|
||||||
|
if (!key || seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const priority = ["time", "sender"];
|
||||||
|
const ordered = [];
|
||||||
|
priority.forEach(function (key) {
|
||||||
|
if (normalized.includes(key)) {
|
||||||
|
ordered.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
normalized.forEach(function (key) {
|
||||||
|
if (!ordered.includes(key)) {
|
||||||
|
ordered.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ordered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedVoiceLabel = function (nodes, fallback) {
|
||||||
|
const match = (nodes || []).find(function (node) {
|
||||||
|
return !!(node && node.checked);
|
||||||
|
});
|
||||||
|
return String(match && match.value ? match.value : fallback || "").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildShareStatements = function (records) {
|
||||||
|
const outLabel = selectedVoiceLabel(exportVoiceOut, "I said");
|
||||||
|
const inLabel = selectedVoiceLabel(exportVoiceIn, "They said");
|
||||||
|
const lines = [];
|
||||||
|
let previous = null;
|
||||||
|
records.forEach(function (row) {
|
||||||
|
const speaker = row.direction === "outgoing" ? "you" : "they";
|
||||||
|
const speakerLabel = speaker === "you" ? (outLabel + ": ") : (inLabel + ": ");
|
||||||
|
const timePrefix = row.time ? ("[" + row.time + "] ") : "";
|
||||||
|
if (!previous) {
|
||||||
|
lines.push(timePrefix + speakerLabel + row.text);
|
||||||
|
previous = row;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deltaMs = Math.max(0, toInt(row.ts) - toInt(previous.ts));
|
||||||
|
const waitLabel = formatElapsedCompact(deltaMs || 0);
|
||||||
|
const phrasing = speaker === "you"
|
||||||
|
? ("After " + waitLabel + ", " + outLabel + ": ")
|
||||||
|
: ("After " + waitLabel + ", " + inLabel + ": ");
|
||||||
|
lines.push(timePrefix + phrasing + row.text);
|
||||||
|
previous = row;
|
||||||
|
});
|
||||||
|
return lines.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toCsvCell = function (value) {
|
||||||
|
const text = String(value || "");
|
||||||
|
if (!/[\",\n]/.test(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return '"' + text.replace(/"/g, '""') + '"';
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportTextForRecords = function (records) {
|
||||||
|
const format = String(exportFormat && exportFormat.value ? exportFormat.value : "plain");
|
||||||
|
const fields = orderedExportFields(selectedExportFields());
|
||||||
|
if (!records.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (format === "share") {
|
||||||
|
return buildShareStatements(records);
|
||||||
|
}
|
||||||
|
if (format === "jsonl") {
|
||||||
|
return records.map(function (row) {
|
||||||
|
const payload = {};
|
||||||
|
fields.forEach(function (field) {
|
||||||
|
payload[field] = row[field];
|
||||||
|
});
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
}).join("\n");
|
||||||
|
}
|
||||||
|
if (format === "csv") {
|
||||||
|
const header = fields.slice();
|
||||||
|
const rows = [header.map(toCsvCell).join(",")];
|
||||||
|
records.forEach(function (row) {
|
||||||
|
const line = fields.map(function (field) {
|
||||||
|
return toCsvCell(row[field]);
|
||||||
|
});
|
||||||
|
rows.push(line.join(","));
|
||||||
|
});
|
||||||
|
return rows.join("\n");
|
||||||
|
}
|
||||||
|
if (format === "markdown") {
|
||||||
|
return records.map(function (row) {
|
||||||
|
const meta = fields.filter(function (field) { return field !== "text"; }).map(function (field) {
|
||||||
|
const value = row[field];
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (field === "time") {
|
||||||
|
return "[" + String(value) + "]";
|
||||||
|
}
|
||||||
|
return "**" + field + "**=" + String(value);
|
||||||
|
}).filter(Boolean).join(" ");
|
||||||
|
const text = fields.includes("text") ? String(row.text || "") : "";
|
||||||
|
if (meta && text) {
|
||||||
|
return "- " + meta + " | " + text;
|
||||||
|
}
|
||||||
|
return "- " + (meta || text);
|
||||||
|
}).join("\n");
|
||||||
|
}
|
||||||
|
return records.map(function (row) {
|
||||||
|
const parts = [];
|
||||||
|
fields.forEach(function (field) {
|
||||||
|
const value = row[field];
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "text") {
|
||||||
|
parts.push(String(value));
|
||||||
|
} else if (field === "time") {
|
||||||
|
parts.push("[" + String(value) + "]");
|
||||||
|
} else {
|
||||||
|
parts.push(field + "=" + String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return parts.join(" ");
|
||||||
|
}).join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExportBuffer = function () {
|
||||||
|
if (!exportBuffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const records = messageRowsToExportRecords();
|
||||||
|
exportBuffer.value = exportTextForRecords(records);
|
||||||
|
if (exportSummary) {
|
||||||
|
const count = records.length;
|
||||||
|
const modeLabel = String(panelState.rangeMode || "inside");
|
||||||
|
if (!panelState.rangeStartId) {
|
||||||
|
exportSummary.textContent = "Select two messages to define a range.";
|
||||||
|
} else if (!panelState.rangeEndId) {
|
||||||
|
exportSummary.textContent = "Start set. Pick an end message.";
|
||||||
|
} else {
|
||||||
|
exportSummary.textContent = count + " messages selected (" + modeLabel + ").";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateRangeSelectionUi();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRangeAnchor = function (messageId) {
|
||||||
|
const id = String(messageId || "").trim();
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!panelState.rangeStartId || (panelState.rangeStartId && panelState.rangeEndId)) {
|
||||||
|
panelState.rangeStartId = id;
|
||||||
|
panelState.rangeEndId = "";
|
||||||
|
} else if (panelState.rangeStartId && !panelState.rangeEndId) {
|
||||||
|
panelState.rangeEndId = id;
|
||||||
|
}
|
||||||
|
updateExportBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearRangeSelection = function () {
|
||||||
|
panelState.rangeStartId = "";
|
||||||
|
panelState.rangeEndId = "";
|
||||||
|
updateExportBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initExportUi = function () {
|
||||||
|
updateExportBuffer();
|
||||||
|
if (exportToggle && exportBox) {
|
||||||
|
exportToggle.addEventListener("click", function () {
|
||||||
|
const hidden = exportBox.classList.toggle("is-hidden");
|
||||||
|
exportToggle.setAttribute("aria-expanded", hidden ? "false" : "true");
|
||||||
|
if (!hidden && exportBuffer) {
|
||||||
|
exportBuffer.focus();
|
||||||
|
exportBuffer.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (exportScope) {
|
||||||
|
exportScope.addEventListener("change", function () {
|
||||||
|
panelState.rangeMode = String(exportScope.value || "inside");
|
||||||
|
updateExportBuffer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (exportFormat) {
|
||||||
|
exportFormat.addEventListener("change", function () {
|
||||||
|
syncExportFieldLocks();
|
||||||
|
updateExportBuffer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
exportVoiceOut.forEach(function (node) { node.addEventListener("change", updateExportBuffer); });
|
||||||
|
exportVoiceIn.forEach(function (node) { node.addEventListener("change", updateExportBuffer); });
|
||||||
|
exportFieldChecks.forEach(function (node) {
|
||||||
|
node.addEventListener("change", updateExportBuffer);
|
||||||
|
});
|
||||||
|
if (exportCopy) {
|
||||||
|
exportCopy.addEventListener("click", async function () {
|
||||||
|
if (!exportBuffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = String(exportBuffer.value || "");
|
||||||
|
if (!text) {
|
||||||
|
setStatus("No export text available for current selection.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setStatus("Copied selected conversation export.", "success");
|
||||||
|
} catch (err) {
|
||||||
|
exportBuffer.focus();
|
||||||
|
exportBuffer.select();
|
||||||
|
setStatus("Clipboard blocked. Press Ctrl+C to copy selected export text.", "warning");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (exportClear) {
|
||||||
|
exportClear.addEventListener("click", function () {
|
||||||
|
clearRangeSelection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (exportBuffer) {
|
||||||
|
exportBuffer.addEventListener("keydown", function (event) {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && String(event.key || "").toLowerCase() === "a") {
|
||||||
|
event.preventDefault();
|
||||||
|
exportBuffer.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
syncExportFieldLocks();
|
||||||
|
updateExportBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
const flashReplyTarget = function (row) {
|
const flashReplyTarget = function (row) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return;
|
return;
|
||||||
@@ -2967,6 +3642,7 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
bindReplyReferences(panel);
|
bindReplyReferences(panel);
|
||||||
|
initExportUi();
|
||||||
if (replyClearBtn) {
|
if (replyClearBtn) {
|
||||||
replyClearBtn.addEventListener("click", function () {
|
replyClearBtn.addEventListener("click", function () {
|
||||||
clearReplyTarget();
|
clearReplyTarget();
|
||||||
@@ -3045,6 +3721,7 @@
|
|||||||
metaLine.textContent = titleCase(service) + " · " + identifier;
|
metaLine.textContent = titleCase(service) + " · " + identifier;
|
||||||
}
|
}
|
||||||
clearReplyTarget();
|
clearReplyTarget();
|
||||||
|
clearRangeSelection();
|
||||||
if (panelState.socket) {
|
if (panelState.socket) {
|
||||||
try {
|
try {
|
||||||
panelState.socket.close();
|
panelState.socket.close();
|
||||||
@@ -3780,10 +4457,10 @@
|
|||||||
document.addEventListener("keydown", panelState.lightboxKeyHandler);
|
document.addEventListener("keydown", panelState.lightboxKeyHandler);
|
||||||
}
|
}
|
||||||
panelState.resizeHandler = function () {
|
panelState.resizeHandler = function () {
|
||||||
if (!popover || popover.classList.contains("is-hidden")) {
|
if (popover && !popover.classList.contains("is-hidden")) {
|
||||||
return;
|
positionPopover(panelState.activePanel);
|
||||||
}
|
}
|
||||||
positionPopover(panelState.activePanel);
|
applyLatencyPacingPairs();
|
||||||
};
|
};
|
||||||
window.addEventListener("resize", panelState.resizeHandler);
|
window.addEventListener("resize", panelState.resizeHandler);
|
||||||
document.addEventListener("mousedown", panelState.docClickHandler);
|
document.addEventListener("mousedown", panelState.docClickHandler);
|
||||||
@@ -4008,6 +4685,7 @@
|
|||||||
document.body.addEventListener("composeSendResult", panelState.sendResultHandler);
|
document.body.addEventListener("composeSendResult", panelState.sendResultHandler);
|
||||||
|
|
||||||
hydrateBodyUrlsAsImages(thread);
|
hydrateBodyUrlsAsImages(thread);
|
||||||
|
applyLatencyPacingPairs();
|
||||||
updateReplyTimingUi();
|
updateReplyTimingUi();
|
||||||
panelState.replyTimingTimer = setInterval(updateReplyTimingUi, 1000);
|
panelState.replyTimingTimer = setInterval(updateReplyTimingUi, 1000);
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
|
|||||||
@@ -440,10 +440,14 @@ def _serialize_message(msg: Message) -> dict:
|
|||||||
read_delta = int(read_ts - ts_val) if read_ts and ts_val else None
|
read_delta = int(read_ts - ts_val) if read_ts and ts_val else None
|
||||||
# Human friendly delta strings
|
# Human friendly delta strings
|
||||||
delivered_delta_display = (
|
delivered_delta_display = (
|
||||||
_format_gap_duration(delivered_delta) if delivered_delta is not None else ""
|
_format_gap_duration(delivered_delta)
|
||||||
|
if delivered_delta is not None and int(delivered_delta) > 0
|
||||||
|
else ""
|
||||||
)
|
)
|
||||||
read_delta_display = (
|
read_delta_display = (
|
||||||
_format_gap_duration(read_delta) if read_delta is not None else ""
|
_format_gap_duration(read_delta)
|
||||||
|
if read_delta is not None and int(read_delta) > 0
|
||||||
|
else ""
|
||||||
)
|
)
|
||||||
# Receipt payload and metadata
|
# Receipt payload and metadata
|
||||||
receipt_payload = msg.receipt_payload or {}
|
receipt_payload = msg.receipt_payload or {}
|
||||||
@@ -850,8 +854,11 @@ def _serialize_messages_with_artifacts(
|
|||||||
and current_ts >= prev_ts
|
and current_ts >= prev_ts
|
||||||
):
|
):
|
||||||
block_gap_ms = current_ts - prev_ts
|
block_gap_ms = current_ts - prev_ts
|
||||||
serialized[idx]["block_gap_ms"] = int(block_gap_ms)
|
if int(block_gap_ms) > 0:
|
||||||
serialized[idx]["block_gap_display"] = _format_gap_duration(block_gap_ms)
|
serialized[idx]["block_gap_ms"] = int(block_gap_ms)
|
||||||
|
serialized[idx]["block_gap_display"] = _format_gap_duration(
|
||||||
|
block_gap_ms
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
prev_msg is not None
|
prev_msg is not None
|
||||||
@@ -937,6 +944,23 @@ def _glance_items_from_state(gap_fragment=None, metric_fragments=None, person_id
|
|||||||
"url": _insight_detail_url(person_id, metric.get("slug")),
|
"url": _insight_detail_url(person_id, metric.get("slug")),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if not metric_fragments:
|
||||||
|
items.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "Stability Score",
|
||||||
|
"value": "n/a",
|
||||||
|
"tooltip": "No stability score available yet for this conversation.",
|
||||||
|
"url": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Stability Confidence",
|
||||||
|
"value": "n/a",
|
||||||
|
"tooltip": "No stability confidence available yet for this conversation.",
|
||||||
|
"url": "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
return items[:3]
|
return items[:3]
|
||||||
|
|
||||||
|
|
||||||
@@ -1767,7 +1791,11 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
|||||||
|
|
||||||
|
|
||||||
def _compose_urls(service, identifier, person_id):
|
def _compose_urls(service, identifier, person_id):
|
||||||
query = {"service": service, "identifier": identifier}
|
service_key = _default_service(service)
|
||||||
|
identifier_value = str(identifier or "").strip()
|
||||||
|
if service_key == "whatsapp" and "@" in identifier_value:
|
||||||
|
identifier_value = identifier_value.split("@", 1)[0].strip()
|
||||||
|
query = {"service": service_key, "identifier": identifier_value}
|
||||||
if person_id:
|
if person_id:
|
||||||
query["person"] = str(person_id)
|
query["person"] = str(person_id)
|
||||||
payload = urlencode(query)
|
payload = urlencode(query)
|
||||||
@@ -1997,14 +2025,26 @@ def _recent_manual_contacts(
|
|||||||
if not all_rows:
|
if not all_rows:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _normalize_recent_identifier(service_value: str, identifier_value: str) -> str:
|
||||||
|
svc = _default_service(service_value)
|
||||||
|
raw = str(identifier_value or "").strip()
|
||||||
|
if svc == "whatsapp" and "@" in raw:
|
||||||
|
return raw.split("@", 1)[0].strip()
|
||||||
|
return raw
|
||||||
|
|
||||||
current_service_key = _default_service(current_service)
|
current_service_key = _default_service(current_service)
|
||||||
current_identifier_value = str(current_identifier or "").strip()
|
current_identifier_value = _normalize_recent_identifier(
|
||||||
|
current_service_key, str(current_identifier or "").strip()
|
||||||
|
)
|
||||||
current_person_id = str(current_person.id) if current_person else ""
|
current_person_id = str(current_person.id) if current_person else ""
|
||||||
|
|
||||||
row_by_key = {
|
row_by_key = {
|
||||||
(
|
(
|
||||||
str(row.get("service") or "").strip().lower(),
|
str(row.get("service") or "").strip().lower(),
|
||||||
str(row.get("identifier") or "").strip(),
|
_normalize_recent_identifier(
|
||||||
|
str(row.get("service") or "").strip().lower(),
|
||||||
|
str(row.get("identifier") or "").strip(),
|
||||||
|
),
|
||||||
): row
|
): row
|
||||||
for row in all_rows
|
for row in all_rows
|
||||||
}
|
}
|
||||||
@@ -2019,7 +2059,9 @@ def _recent_manual_contacts(
|
|||||||
if not person_id:
|
if not person_id:
|
||||||
continue
|
continue
|
||||||
service_key = _default_service(link.service)
|
service_key = _default_service(link.service)
|
||||||
identifier_value = str(link.identifier or "").strip()
|
identifier_value = _normalize_recent_identifier(
|
||||||
|
service_key, str(link.identifier or "").strip()
|
||||||
|
)
|
||||||
if not identifier_value:
|
if not identifier_value:
|
||||||
continue
|
continue
|
||||||
by_person_service.setdefault(person_id, {})
|
by_person_service.setdefault(person_id, {})
|
||||||
@@ -2042,9 +2084,13 @@ def _recent_manual_contacts(
|
|||||||
.order_by("-ts", "-id")[:1000]
|
.order_by("-ts", "-id")[:1000]
|
||||||
)
|
)
|
||||||
for service_value, identifier_value in recent_values:
|
for service_value, identifier_value in recent_values:
|
||||||
|
service_key = _default_service(service_value)
|
||||||
|
normalized_identifier = _normalize_recent_identifier(
|
||||||
|
service_key, str(identifier_value or "").strip()
|
||||||
|
)
|
||||||
key = (
|
key = (
|
||||||
_default_service(service_value),
|
service_key,
|
||||||
str(identifier_value or "").strip(),
|
normalized_identifier,
|
||||||
)
|
)
|
||||||
if not key[1] or key in seen_keys:
|
if not key[1] or key in seen_keys:
|
||||||
continue
|
continue
|
||||||
@@ -2113,6 +2159,9 @@ def _recent_manual_contacts(
|
|||||||
).strip()
|
).strip()
|
||||||
break
|
break
|
||||||
selected_identifier = selected_identifier or identifier_value
|
selected_identifier = selected_identifier or identifier_value
|
||||||
|
selected_identifier = _normalize_recent_identifier(
|
||||||
|
selected_service, selected_identifier
|
||||||
|
)
|
||||||
selected_urls = _compose_urls(
|
selected_urls = _compose_urls(
|
||||||
selected_service,
|
selected_service,
|
||||||
selected_identifier,
|
selected_identifier,
|
||||||
@@ -2134,6 +2183,7 @@ def _recent_manual_contacts(
|
|||||||
svc_identifier = str(
|
svc_identifier = str(
|
||||||
(service_map.get(svc) or {}).get("identifier") or ""
|
(service_map.get(svc) or {}).get("identifier") or ""
|
||||||
).strip()
|
).strip()
|
||||||
|
svc_identifier = _normalize_recent_identifier(svc, svc_identifier)
|
||||||
row[f"{svc}_identifier"] = svc_identifier
|
row[f"{svc}_identifier"] = svc_identifier
|
||||||
if svc_identifier:
|
if svc_identifier:
|
||||||
svc_urls = _compose_urls(svc, svc_identifier, person_id)
|
svc_urls = _compose_urls(svc, svc_identifier, person_id)
|
||||||
@@ -2150,7 +2200,9 @@ def _recent_manual_contacts(
|
|||||||
row["service_label"] = _service_label(service_key)
|
row["service_label"] = _service_label(service_key)
|
||||||
for svc in ("signal", "whatsapp", "instagram", "xmpp"):
|
for svc in ("signal", "whatsapp", "instagram", "xmpp"):
|
||||||
row[f"{svc}_identifier"] = (
|
row[f"{svc}_identifier"] = (
|
||||||
identifier_value if svc == service_key else ""
|
_normalize_recent_identifier(service_key, identifier_value)
|
||||||
|
if svc == service_key
|
||||||
|
else ""
|
||||||
)
|
)
|
||||||
row[f"{svc}_compose_url"] = (
|
row[f"{svc}_compose_url"] = (
|
||||||
row.get("compose_url") if svc == service_key else ""
|
row.get("compose_url") if svc == service_key else ""
|
||||||
@@ -2161,7 +2213,11 @@ def _recent_manual_contacts(
|
|||||||
|
|
||||||
row["is_active"] = (
|
row["is_active"] = (
|
||||||
row.get("service") == current_service_key
|
row.get("service") == current_service_key
|
||||||
and str(row.get("identifier") or "").strip() == current_identifier_value
|
and _normalize_recent_identifier(
|
||||||
|
str(row.get("service") or "").strip().lower(),
|
||||||
|
str(row.get("identifier") or "").strip(),
|
||||||
|
)
|
||||||
|
== current_identifier_value
|
||||||
)
|
)
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
if len(rows) >= limit:
|
if len(rows) >= limit:
|
||||||
|
|||||||
Reference in New Issue
Block a user