Tightly integrate WhatsApp selectors into existing UIs
This commit is contained in:
@@ -2,18 +2,62 @@
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">Manual Text Mode</p>
|
||||
<p class="is-size-6" style="margin-bottom: 0;">
|
||||
{% if person %}
|
||||
{{ person.name }}
|
||||
{% else %}
|
||||
{{ identifier }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="is-size-7 compose-meta-line" style="margin-bottom: 0;">
|
||||
{% if recent_contacts %}
|
||||
<div class="compose-contact-switch">
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-contact-select" class="compose-contact-select">
|
||||
{% for option in recent_contacts %}
|
||||
<option
|
||||
value="{{ option.identifier }}"
|
||||
data-service="{{ option.service }}"
|
||||
data-identifier="{{ option.identifier }}"
|
||||
data-person="{{ option.person_id }}"
|
||||
data-page-url="{{ option.compose_url }}"
|
||||
data-widget-url="{{ option.compose_widget_url }}"
|
||||
{% if option.is_active %}selected{% endif %}>
|
||||
{{ option.person_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="is-size-6" style="margin-bottom: 0;">
|
||||
{% if person %}
|
||||
{{ person.name }}
|
||||
{% else %}
|
||||
{{ identifier }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p id="{{ panel_id }}-meta-line" class="is-size-7 compose-meta-line" style="margin-bottom: 0;">
|
||||
{{ service|title }} · {{ identifier }}
|
||||
</p>
|
||||
{% if platform_options %}
|
||||
<div class="compose-platform-switch">
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-platform-select" class="compose-platform-select">
|
||||
{% for option in platform_options %}
|
||||
<option
|
||||
value="{{ option.service }}"
|
||||
data-identifier="{{ option.identifier }}"
|
||||
data-person="{{ option.person_id }}"
|
||||
data-page-url="{{ option.page_url }}"
|
||||
data-widget-url="{{ option.widget_url }}"
|
||||
{% if option.is_active %}selected{% endif %}>
|
||||
{{ option.service_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="buttons are-small compose-top-actions" style="margin: 0;">
|
||||
<button type="button" class="button is-light is-rounded compose-history-sync-btn">
|
||||
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||
<span>Force Sync</span>
|
||||
</button>
|
||||
<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>Drafts</span>
|
||||
@@ -182,6 +226,7 @@
|
||||
data-drafts-url="{{ compose_drafts_url }}"
|
||||
data-summary-url="{{ compose_summary_url }}"
|
||||
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
||||
data-history-sync-url="{{ compose_history_sync_url }}"
|
||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||
{% for msg in serialized_messages %}
|
||||
@@ -231,7 +276,13 @@
|
||||
</article>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
||||
<div class="compose-empty-wrap">
|
||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
||||
<button type="button" class="button is-light is-small compose-history-sync-btn">
|
||||
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||
<span>Force History Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
|
||||
@@ -245,16 +296,14 @@
|
||||
hx-post="{% url 'compose_send' %}"
|
||||
hx-target="#{{ panel_id }}-status"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="service" value="{{ service }}">
|
||||
<input type="hidden" name="identifier" value="{{ identifier }}">
|
||||
<input id="{{ panel_id }}-input-service" type="hidden" name="service" value="{{ service }}">
|
||||
<input id="{{ panel_id }}-input-identifier" type="hidden" name="identifier" value="{{ identifier }}">
|
||||
<input id="{{ panel_id }}-input-person" type="hidden" name="person" value="{% if person %}{{ person.id }}{% endif %}">
|
||||
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
||||
<input type="hidden" name="limit" value="{{ limit }}">
|
||||
<input type="hidden" name="panel_id" value="{{ panel_id }}">
|
||||
<input type="hidden" name="failsafe_arm" value="0">
|
||||
<input type="hidden" name="failsafe_confirm" value="0">
|
||||
{% if person %}
|
||||
<input type="hidden" name="person" value="{{ person.id }}">
|
||||
{% endif %}
|
||||
<div class="compose-send-safety">
|
||||
<label class="checkbox is-size-7">
|
||||
<input type="checkbox" class="manual-confirm"> Confirm Send
|
||||
@@ -477,6 +526,19 @@
|
||||
#{{ panel_id }} .compose-msg-meta {
|
||||
margin: 0;
|
||||
}
|
||||
#{{ panel_id }} .compose-platform-switch {
|
||||
margin-top: 0.32rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-contact-switch {
|
||||
margin-bottom: 0.08rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-contact-select {
|
||||
min-width: 15rem;
|
||||
max-width: min(80vw, 30rem);
|
||||
}
|
||||
#{{ panel_id }} .compose-platform-select {
|
||||
min-width: 11rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-gap-artifacts {
|
||||
align-self: center;
|
||||
width: min(92%, 34rem);
|
||||
@@ -554,6 +616,16 @@
|
||||
color: #6f6f6f;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-empty-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#{{ panel_id }} .compose-history-sync-btn.is-loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#{{ panel_id }} .compose-typing {
|
||||
margin: 0 0 0.5rem 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
@@ -1062,6 +1134,13 @@
|
||||
const thread = document.getElementById(panelId + "-thread");
|
||||
const form = document.getElementById(panelId + "-form");
|
||||
const textarea = document.getElementById(panelId + "-textarea");
|
||||
const platformSelect = document.getElementById(panelId + "-platform-select");
|
||||
const contactSelect = document.getElementById(panelId + "-contact-select");
|
||||
const metaLine = document.getElementById(panelId + "-meta-line");
|
||||
const hiddenService = document.getElementById(panelId + "-input-service");
|
||||
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
|
||||
const hiddenPerson = document.getElementById(panelId + "-input-person");
|
||||
const renderMode = "{{ render_mode }}";
|
||||
if (!thread || !form || !textarea) {
|
||||
return;
|
||||
}
|
||||
@@ -1240,9 +1319,9 @@
|
||||
gap: null,
|
||||
metrics: [],
|
||||
};
|
||||
const personId = String(thread.dataset.person || "").trim();
|
||||
const insightUrlForMetric = function (metricSlug) {
|
||||
const slug = String(metricSlug || "").trim();
|
||||
const personId = String(thread.dataset.person || "").trim();
|
||||
if (!personId || !slug) {
|
||||
return "";
|
||||
}
|
||||
@@ -1273,6 +1352,109 @@
|
||||
}
|
||||
};
|
||||
|
||||
const bindHistorySyncButtons = function (rootNode) {
|
||||
const scope = rootNode || panel;
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
scope.querySelectorAll(".compose-history-sync-btn").forEach(function (button) {
|
||||
if (button.dataset.bound === "1") {
|
||||
return;
|
||||
}
|
||||
button.dataset.bound = "1";
|
||||
button.addEventListener("click", async function () {
|
||||
const historySyncUrl = String(thread.dataset.historySyncUrl || "").trim();
|
||||
if (!historySyncUrl) {
|
||||
setStatus("History sync endpoint is unavailable.", "warning");
|
||||
return;
|
||||
}
|
||||
button.classList.add("is-loading");
|
||||
setStatus("Requesting history sync…", "info");
|
||||
try {
|
||||
const payload = new URLSearchParams();
|
||||
payload.set("service", thread.dataset.service || "");
|
||||
payload.set("identifier", thread.dataset.identifier || "");
|
||||
if (thread.dataset.person) {
|
||||
payload.set("person", thread.dataset.person);
|
||||
}
|
||||
payload.set("limit", thread.dataset.limit || "60");
|
||||
const response = await fetch(historySyncUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRFToken": csrfToken,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json"
|
||||
},
|
||||
body: payload.toString(),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!result.ok) {
|
||||
setStatus(
|
||||
String(result.message || result.error || "History sync failed."),
|
||||
String(result.level || "danger")
|
||||
);
|
||||
} else {
|
||||
setStatus(
|
||||
String(result.message || "History sync requested."),
|
||||
String(result.level || "success")
|
||||
);
|
||||
}
|
||||
await poll(true);
|
||||
if (result.ok) {
|
||||
window.setTimeout(function () {
|
||||
poll(false);
|
||||
}, 1800);
|
||||
window.setTimeout(function () {
|
||||
poll(false);
|
||||
}, 4200);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus("History sync request failed.", "danger");
|
||||
} finally {
|
||||
button.classList.remove("is-loading");
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const ensureEmptyState = function (messageText) {
|
||||
if (!thread) {
|
||||
return;
|
||||
}
|
||||
if (thread.querySelector(".compose-row")) {
|
||||
return;
|
||||
}
|
||||
thread.querySelectorAll(".compose-empty").forEach(function (node) {
|
||||
if (!node.closest(".compose-empty-wrap")) {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
let wrap = thread.querySelector(".compose-empty-wrap");
|
||||
if (!wrap) {
|
||||
wrap = document.createElement("div");
|
||||
wrap.className = "compose-empty-wrap";
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "compose-empty";
|
||||
empty.textContent = String(
|
||||
messageText || "No stored messages for this contact yet."
|
||||
);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button is-light is-small compose-history-sync-btn";
|
||||
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span><span>Force History Sync</span>';
|
||||
wrap.appendChild(empty);
|
||||
wrap.appendChild(button);
|
||||
thread.appendChild(wrap);
|
||||
} else {
|
||||
const empty = wrap.querySelector(".compose-empty");
|
||||
if (empty && messageText) {
|
||||
empty.textContent = String(messageText);
|
||||
}
|
||||
}
|
||||
bindHistorySyncButtons(wrap);
|
||||
};
|
||||
|
||||
const extractUrlCandidates = function (value) {
|
||||
const raw = String(value || "");
|
||||
const matches = raw.match(/https?:\/\/[^\s<>'"\\]+/g) || [];
|
||||
@@ -1550,6 +1732,10 @@
|
||||
if (empty) {
|
||||
empty.remove();
|
||||
}
|
||||
const emptyWrap = thread.querySelector(".compose-empty-wrap");
|
||||
if (emptyWrap) {
|
||||
emptyWrap.remove();
|
||||
}
|
||||
thread.appendChild(row);
|
||||
wireImageFallbacks(row);
|
||||
updateGlanceFromMessage(msg);
|
||||
@@ -1651,6 +1837,7 @@
|
||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
}
|
||||
ensureEmptyState();
|
||||
} catch (err) {
|
||||
console.debug("compose poll error", err);
|
||||
} finally {
|
||||
@@ -1729,6 +1916,7 @@
|
||||
// Ignore invalid initial typing state payload.
|
||||
}
|
||||
applyMinuteGrouping();
|
||||
bindHistorySyncButtons(panel);
|
||||
|
||||
const setStatus = function (message, level) {
|
||||
if (!statusBox) {
|
||||
@@ -1815,6 +2003,78 @@
|
||||
return params;
|
||||
};
|
||||
|
||||
const titleCase = function (value) {
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
if (raw === "whatsapp") {
|
||||
return "WhatsApp";
|
||||
}
|
||||
if (raw === "xmpp") {
|
||||
return "XMPP";
|
||||
}
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||
};
|
||||
|
||||
const switchThreadContext = function (nextService, nextIdentifier, nextPersonId, pageUrl) {
|
||||
const service = String(nextService || "").trim().toLowerCase();
|
||||
const identifier = String(nextIdentifier || "").trim();
|
||||
const personId = String(nextPersonId || "").trim();
|
||||
if (!service || !identifier) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
String(thread.dataset.service || "").toLowerCase() === service
|
||||
&& String(thread.dataset.identifier || "") === identifier
|
||||
&& String(thread.dataset.person || "") === personId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
thread.dataset.service = service;
|
||||
thread.dataset.identifier = identifier;
|
||||
if (personId) {
|
||||
thread.dataset.person = personId;
|
||||
} else {
|
||||
delete thread.dataset.person;
|
||||
}
|
||||
if (hiddenService) {
|
||||
hiddenService.value = service;
|
||||
}
|
||||
if (hiddenIdentifier) {
|
||||
hiddenIdentifier.value = identifier;
|
||||
}
|
||||
if (hiddenPerson) {
|
||||
hiddenPerson.value = personId;
|
||||
}
|
||||
if (metaLine) {
|
||||
metaLine.textContent = titleCase(service) + " · " + identifier;
|
||||
}
|
||||
if (panelState.socket) {
|
||||
try {
|
||||
panelState.socket.close();
|
||||
} catch (err) {
|
||||
// Ignore socket close errors.
|
||||
}
|
||||
panelState.socket = null;
|
||||
}
|
||||
panelState.websocketReady = false;
|
||||
hideAllCards();
|
||||
thread.innerHTML = '<p class="compose-empty">Loading messages...</p>';
|
||||
lastTs = 0;
|
||||
thread.dataset.lastTs = "0";
|
||||
glanceState = { gap: null, metrics: [] };
|
||||
renderGlanceItems([]);
|
||||
if (renderMode === "page" && pageUrl) {
|
||||
try {
|
||||
window.history.replaceState({}, "", String(pageUrl));
|
||||
} catch (err) {
|
||||
// Ignore history API failures.
|
||||
}
|
||||
}
|
||||
poll(true);
|
||||
};
|
||||
|
||||
const setCardLoading = function (card, loading) {
|
||||
const loadingNode = card.querySelector(".compose-ai-loading");
|
||||
const contentNode = card.querySelector(".compose-ai-content");
|
||||
@@ -2397,6 +2657,50 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
if (platformSelect) {
|
||||
platformSelect.addEventListener("change", function () {
|
||||
const selected = platformSelect.options[platformSelect.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const selectedService = selected.value || "";
|
||||
const selectedIdentifier = selected.dataset.identifier || "";
|
||||
const selectedPerson = selected.dataset.person || thread.dataset.person || "";
|
||||
const selectedPageUrl = (
|
||||
renderMode === "page"
|
||||
? selected.dataset.pageUrl
|
||||
: selected.dataset.widgetUrl
|
||||
) || "";
|
||||
switchThreadContext(
|
||||
selectedService,
|
||||
selectedIdentifier,
|
||||
selectedPerson,
|
||||
selectedPageUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
if (contactSelect) {
|
||||
contactSelect.addEventListener("change", function () {
|
||||
const selected = contactSelect.options[contactSelect.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const selectedService = selected.dataset.service || "";
|
||||
const selectedIdentifier = selected.dataset.identifier || "";
|
||||
const selectedPerson = selected.dataset.person || "";
|
||||
const selectedPageUrl = (
|
||||
renderMode === "page"
|
||||
? selected.dataset.pageUrl
|
||||
: selected.dataset.widgetUrl
|
||||
) || "";
|
||||
switchThreadContext(
|
||||
selectedService,
|
||||
selectedIdentifier,
|
||||
selectedPerson,
|
||||
selectedPageUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
panelState.docClickHandler = function (event) {
|
||||
if (!panel.contains(event.target)) {
|
||||
|
||||
Reference in New Issue
Block a user