Tightly integrate WhatsApp selectors into existing UIs

This commit is contained in:
2026-02-16 10:51:57 +00:00
parent a38339c809
commit 15af8af6b2
19 changed files with 2846 additions and 156 deletions

View File

@@ -112,6 +112,7 @@
hx-swap="innerHTML">
<input type="hidden" id="draft-send-input-{{ person.id }}-{{ operation }}" name="draft_text" value="">
<input type="hidden" id="draft-send-force-{{ person.id }}-{{ operation }}" name="force_send" value="0">
<input type="hidden" name="target_identifier_id" value="{{ send_target_bundle.selected_id }}">
<div class="field">
<label class="label is-small">Draft Preview</label>
<div class="control">

View File

@@ -388,7 +388,23 @@
<input type="hidden" name="active_tab" value="{{ active_tab|default:'engage' }}">
<input type="hidden" id="engage-action-input-{{ person.id }}" name="action" value="preview">
<input type="hidden" id="engage-force-send-{{ person.id }}" name="force_send" value="0">
<input type="hidden" id="engage-target-input-{{ person.id }}" name="target_identifier_id" value="{{ engage_form.target_identifier_id }}">
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
{% if send_target_bundle.options %}
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Target Platform</label>
<div class="select is-small is-fullwidth">
<select id="engage-target-select-{{ person.id }}" onchange="giaEngageSetTarget('{{ person.id }}', this.value);">
{% for option in send_target_bundle.options %}
<option value="{{ option.id }}" {% if option.id == engage_form.target_identifier_id %}selected{% endif %}>
{{ option.service_label }} · {{ option.identifier }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Source</label>
<div class="select is-small is-fullwidth">
@@ -897,6 +913,19 @@
form.requestSubmit();
};
window.giaEngageSetTarget = function(pid, targetId) {
if (pid !== personId) return;
const normalized = String(targetId || "").trim();
const input = document.getElementById("engage-target-input-" + pid);
if (input) {
input.value = normalized;
}
const parentSelect = document.getElementById("ai-target-select-" + pid);
if (parentSelect && normalized) {
parentSelect.value = normalized;
}
};
window.giaEngageSelect = function(pid, kind, value, node) {
if (pid !== personId) return;
let inputId = "";
@@ -922,6 +951,14 @@
window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}");
resizeEditableTextareas(document.getElementById("mitigation-shell-" + personId));
const parentTarget = document.getElementById("ai-target-select-" + personId);
if (parentTarget) {
window.giaEngageSetTarget(personId, parentTarget.value);
}
const localTarget = document.getElementById("engage-target-select-" + personId);
if (localTarget) {
window.giaEngageSetTarget(personId, localTarget.value);
}
window.giaEngageSyncSendOverride(personId);
})();
</script>

View File

@@ -72,12 +72,13 @@
{% endif %}
{% if compose_page_url %}
<div class="buttons are-small" style="margin-top: 0.45rem; margin-bottom: 0;">
<a class="button is-light" href="{{ compose_page_url }}">
<a id="ai-manual-link-{{ person.id }}" class="button is-light" href="{{ compose_page_url }}">
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>Manual Text Mode</span>
</a>
{% if compose_widget_url %}
<button
id="ai-manual-widget-btn-{{ person.id }}"
type="button"
class="button is-light is-small js-widget-spawn-trigger is-hidden"
data-widget-url="{{ compose_widget_url }}"
@@ -111,6 +112,26 @@
{% endif %}
</div>
</div>
{% if send_target_bundle.options %}
<div class="field" style="margin-top: 0.55rem; margin-bottom: 0;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Target Platform</label>
<div class="control">
<div class="select is-small is-fullwidth">
<select id="ai-target-select-{{ person.id }}" aria-label="Target platform and identifier">
{% for option in send_target_bundle.options %}
<option
value="{{ option.id }}"
data-service="{{ option.service }}"
data-identifier="{{ option.identifier }}"
{% if option.id == send_target_bundle.selected_id %}selected{% endif %}>
{{ option.service_label }} · {{ option.identifier }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
{% endif %}
<div id="draft-top-status-{{ person.id }}" style="margin-top: 0.5rem;"></div>
</div>
@@ -719,6 +740,10 @@
}
const payload = new URLSearchParams();
payload.append("draft_text", text);
const targetId = getSelectedTargetId();
if (targetId) {
payload.append("target_identifier_id", targetId);
}
fetch(queueUrl, {
method: "POST",
headers: {
@@ -740,6 +765,74 @@
});
};
function getSelectedTargetId() {
const select = document.getElementById("ai-target-select-" + personId);
if (!select) {
return "";
}
return String(select.value || "").trim();
}
function syncTargetInputs() {
const targetId = getSelectedTargetId();
widget.querySelectorAll('input[name="target_identifier_id"]').forEach(function(input) {
input.value = targetId;
});
const engageSelect = document.getElementById("engage-target-select-" + personId);
if (engageSelect && targetId) {
engageSelect.value = targetId;
}
syncComposeTargets();
}
function syncComposeTargets() {
const targetSelectNode = document.getElementById("ai-target-select-" + personId);
if (!targetSelectNode) {
return;
}
const selected = targetSelectNode.options[targetSelectNode.selectedIndex];
if (!selected) {
return;
}
const service = String(selected.dataset.service || "").trim();
const identifier = String(selected.dataset.identifier || "").trim();
if (!service || !identifier) {
return;
}
const params = new URLSearchParams({
service: service,
identifier: identifier,
person: "{{ person.id }}",
});
const manualLink = document.getElementById("ai-manual-link-" + personId);
if (manualLink) {
manualLink.href = "{{ compose_page_base_url }}?" + params.toString();
}
const widgetBtn = document.getElementById("ai-manual-widget-btn-" + personId);
if (widgetBtn) {
const widgetParams = new URLSearchParams({
service: service,
identifier: identifier,
person: "{{ person.id }}",
limit: "{{ limit }}",
});
const widgetUrl = "{{ compose_widget_base_url }}?" + widgetParams.toString();
widgetBtn.dataset.widgetUrl = widgetUrl;
widgetBtn.setAttribute("hx-get", widgetUrl);
}
}
const targetSelect = document.getElementById("ai-target-select-" + personId);
if (targetSelect) {
targetSelect.addEventListener("change", function() {
syncTargetInputs();
});
}
widget.addEventListener("htmx:afterSwap", function() {
syncTargetInputs();
});
if (typeof window.giaMitigationShowTab !== "function") {
window.giaMitigationShowTab = function(pid, tabName) {
const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
@@ -839,5 +932,6 @@
}
window.giaWorkspaceOpenTab(personId, "plan_board", false);
syncTargetInputs();
})();
</script>

View File

@@ -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)) {

View File

@@ -62,6 +62,7 @@
</a>
{% else %}
<button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url contacts_url_name type=type pk=item %}"
hx-trigger="click"
@@ -75,6 +76,7 @@
</span>
</button>
<button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url chats_url_name type=type pk=item %}"
hx-trigger="click"

View File

@@ -33,6 +33,7 @@
</a>
{% else %}
<button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"

View File

@@ -1,53 +1,231 @@
{% include 'mixins/partials/notify.html' %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
<div
id="whatsapp-contacts-shell"
hx-target="#whatsapp-contacts-shell"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>name</th>
<th>identifier</th>
<th>jid</th>
<th>person</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>{{ item.name|default:"-" }}</td>
<td>
<code>{{ item.identifier }}</code>
</td>
<td>{{ item.jid|default:"-" }}</td>
<td>{{ item.person_name|default:"-" }}</td>
<td>
<div class="buttons">
{% if type == 'page' %}
<a href="{{ item.compose_page_url }}" class="button" title="Open manual chat">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</a>
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button"
title="Open manual chat widget">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</button>
{% endif %}
<a href="{{ item.match_url }}" class="button" title="Match identifier">
<span class="icon"><i class="fa-solid fa-link"></i></span>
</a>
<div class="field has-addons is-flex-wrap-wrap" style="margin-bottom: 0.6rem;">
<div class="control is-expanded" style="min-width: 14rem;">
<input
id="whatsapp-contacts-search"
class="input"
type="text"
placeholder="Search WhatsApp contacts...">
</div>
<div class="control">
<button id="whatsapp-contacts-reset" class="button is-light" type="button">
Reset
</button>
</div>
</div>
<div class="osint-results-meta">
<div class="osint-results-meta-left">
<div class="dropdown is-hoverable" id="whatsapp-contacts-columns-dropdown">
<div class="dropdown-trigger">
<button class="button is-small is-light" aria-haspopup="true" aria-controls="whatsapp-contacts-columns-menu">
<span>Show/Hide Fields</span>
<span class="icon is-small"><i class="fa-solid fa-angle-down"></i></span>
</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
</tr>
{% endfor %}
</table>
<div class="dropdown-menu" id="whatsapp-contacts-columns-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item whatsapp-col-toggle" data-col-index="0" href="#" onclick="return false;">
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
<span>Name</span>
<span class="is-size-7 has-text-grey ml-2">(name)</span>
</a>
<a class="dropdown-item whatsapp-col-toggle" data-col-index="1" href="#" onclick="return false;">
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
<span>Identifier</span>
<span class="is-size-7 has-text-grey ml-2">(identifier)</span>
</a>
<a class="dropdown-item whatsapp-col-toggle" data-col-index="2" href="#" onclick="return false;">
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
<span>JID</span>
<span class="is-size-7 has-text-grey ml-2">(jid)</span>
</a>
<a class="dropdown-item whatsapp-col-toggle" data-col-index="3" href="#" onclick="return false;">
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
<span>Person</span>
<span class="is-size-7 has-text-grey ml-2">(person)</span>
</a>
</div>
</div>
</div>
<button class="button is-small is-light" type="button" disabled>
<span class="icon is-small"><i class="fa-solid fa-database"></i></span>
<span>Static</span>
</button>
</div>
<p class="osint-results-count">
fetched {{ object_list|length }} result{% if object_list|length != 1 %}s{% endif %}
</p>
</div>
<div class="table-container">
<table
class="table is-fullwidth is-hoverable"
id="whatsapp-contacts-table">
<thead>
<tr>
<th data-whatsapp-col="0" class="whatsapp-col-0">name</th>
<th data-whatsapp-col="1" class="whatsapp-col-1">identifier</th>
<th data-whatsapp-col="2" class="whatsapp-col-2">jid</th>
<th data-whatsapp-col="3" class="whatsapp-col-3">person</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for item in object_list %}
<tr data-search="{{ item.name|default:'-'|lower }} {{ item.identifier|lower }} {{ item.jid|default:'-'|lower }} {{ item.person_name|default:'-'|lower }}">
<td data-whatsapp-col="0" class="whatsapp-col-0">{{ item.name|default:"-" }}</td>
<td data-whatsapp-col="1" class="whatsapp-col-1">
<code>{{ item.identifier }}</code>
</td>
<td data-whatsapp-col="2" class="whatsapp-col-2">{{ item.jid|default:"-" }}</td>
<td data-whatsapp-col="3" class="whatsapp-col-3">{{ item.person_name|default:"-" }}</td>
<td>
<div class="buttons">
{% if type == 'page' %}
<a href="{{ item.compose_page_url }}" class="button" title="Open manual chat">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</a>
{% else %}
<button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button"
title="Open manual chat widget">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</button>
{% endif %}
<a href="{{ item.match_url }}" class="button" title="Match identifier">
<span class="icon"><i class="fa-solid fa-link"></i></span>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<style>
#whatsapp-contacts-shell .osint-results-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
#whatsapp-contacts-shell .osint-results-meta-left {
display: inline-flex;
align-items: center;
gap: 0.42rem;
flex-wrap: wrap;
}
#whatsapp-contacts-shell .osint-results-count {
margin: 0;
color: #6c757d;
font-size: 0.75rem;
}
#whatsapp-contacts-shell .whatsapp-col-toggle .icon {
color: #3273dc;
}
#whatsapp-contacts-shell .whatsapp-col-toggle.is-hidden-col .icon {
color: #b5b5b5;
}
</style>
<script>
(function () {
const shell = document.getElementById("whatsapp-contacts-shell");
if (!shell) return;
const table = document.getElementById("whatsapp-contacts-table");
if (!table) return;
const rows = Array.from(table.querySelectorAll("tbody tr[data-search]"));
const searchInput = document.getElementById("whatsapp-contacts-search");
const resetBtn = document.getElementById("whatsapp-contacts-reset");
const toggles = Array.from(shell.querySelectorAll(".whatsapp-col-toggle"));
const storageKey = "gia_whatsapp_contacts_hidden_cols_v1";
let hidden = [];
try {
hidden = JSON.parse(localStorage.getItem(storageKey) || "[]");
} catch (e) {
hidden = [];
}
if (!Array.isArray(hidden)) hidden = [];
hidden = hidden.map(String);
function applyFilters() {
const query = String(searchInput?.value || "").trim().toLowerCase();
rows.forEach((row) => {
const hay = String(row.dataset.search || "").toLowerCase();
row.style.display = !query || hay.includes(query) ? "" : "none";
});
}
function applyColumns() {
const hiddenSet = new Set(hidden);
shell.querySelectorAll("[data-whatsapp-col]").forEach((node) => {
const idx = String(node.getAttribute("data-whatsapp-col") || "");
node.style.display = hiddenSet.has(idx) ? "none" : "";
});
toggles.forEach((toggle) => {
const idx = String(toggle.getAttribute("data-col-index") || "");
const isHidden = hiddenSet.has(idx);
toggle.classList.toggle("is-hidden-col", isHidden);
const icon = toggle.querySelector("i");
if (icon) {
icon.className = isHidden ? "fa-solid fa-xmark" : "fa-solid fa-check";
}
});
}
function persistColumns() {
try {
localStorage.setItem(storageKey, JSON.stringify(hidden));
} catch (e) {
// Ignore storage failures.
}
}
toggles.forEach((toggle) => {
toggle.addEventListener("click", function () {
const idx = String(toggle.getAttribute("data-col-index") || "");
if (!idx) return;
if (hidden.includes(idx)) {
hidden = hidden.filter((item) => item !== idx);
} else {
hidden.push(idx);
}
persistColumns();
applyColumns();
});
});
if (searchInput) searchInput.addEventListener("input", applyFilters);
if (resetBtn) {
resetBtn.addEventListener("click", function () {
if (searchInput) searchInput.value = "";
applyFilters();
});
}
applyFilters();
applyColumns();
})();
</script>
</div>