Increase platform abstraction cohesion

This commit is contained in:
2026-03-06 17:47:58 +00:00
parent 438e561da0
commit 8c091b1e6d
55 changed files with 6555 additions and 440 deletions

View File

@@ -381,6 +381,47 @@
</span>
{% endfor %}
</div>
<div
id="{{ panel_id }}-availability-summary"
class="tags are-small mt-1{% if not availability_summary %} is-hidden{% endif %}"
data-summary='{{ availability_summary_json|default:"{}"|escapejs }}'
aria-label="Contact availability summary">
{% if availability_summary %}
<span class="tag is-light {% if availability_summary.state == 'available' %}is-success{% elif availability_summary.state == 'fading' %}is-warning{% elif availability_summary.state == 'unavailable' %}is-danger{% endif %}">
{{ availability_summary.state_label }}
</span>
<span class="tag is-light">{{ availability_summary.service|upper|default:"-" }}</span>
{% if availability_summary.ts_label %}
<span class="tag is-light">Updated {{ availability_summary.ts_label }}</span>
{% endif %}
{% if availability_summary.is_cross_service %}
<span class="tag is-light">Cross-service fallback</span>
{% endif %}
{% endif %}
</div>
<div class="compose-history-nav" role="tablist" aria-label="Conversation views">
<button
type="button"
class="compose-history-tab is-active"
data-target="thread"
aria-selected="true">
Thread
</button>
<button
type="button"
class="compose-history-tab"
data-target="deleted"
aria-selected="false">
Deleted
<span id="{{ panel_id }}-deleted-count" class="compose-history-count">0</span>
</button>
</div>
<div id="{{ panel_id }}-deleted" class="compose-deleted-pane is-hidden">
<p id="{{ panel_id }}-deleted-empty" class="compose-empty">No deleted messages noted yet.</p>
<div id="{{ panel_id }}-deleted-list" class="compose-deleted-list"></div>
</div>
<div
id="{{ panel_id }}-thread"
@@ -397,12 +438,14 @@
data-quick-insights-url="{{ compose_quick_insights_url }}"
data-history-sync-url="{{ compose_history_sync_url }}"
data-react-url="{% url 'compose_react' %}"
data-capability-reactions="{% if capability_reactions %}1{% else %}0{% endif %}"
data-capability-reactions-reason="{{ capability_reactions_reason|default:''|escape }}"
data-reaction-actor-prefix="web:{{ request.user.id }}:"
data-toggle-command-url="{{ compose_toggle_command_url }}"
data-engage-preview-url="{{ compose_engage_preview_url }}"
data-engage-send-url="{{ compose_engage_send_url }}">
{% 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 }}" data-author="{{ msg.author|default:''|escape }}" data-sender-uuid="{{ msg.sender_uuid|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 }}">
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}{% if msg.is_deleted %} is-deleted{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}" data-author="{{ msg.author|default:''|escape }}" data-sender-uuid="{{ msg.sender_uuid|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 %}" data-is-deleted="{% if msg.is_deleted %}1{% else %}0{% endif %}" data-deleted-ts="{{ msg.deleted_ts|default:0 }}" data-deleted-display="{{ msg.deleted_display|default:''|escape }}" data-deleted-actor="{{ msg.deleted_actor|default:''|escape }}" data-deleted-source="{{ msg.deleted_source_service|default:''|escape }}" data-edit-count="{{ msg.edit_count|default:0 }}" data-edit-history="{{ msg.edit_history_json|default:'[]'|escapejs }}" data-raw-text="{{ msg.text|default:''|truncatechars:220|escape }}"{% 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 %}
{% with gap=msg.gap_fragments.0 %}
<p
@@ -460,7 +503,26 @@
{% else %}
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
{% endif %}
{% if service == "signal" or service == "whatsapp" %}
{% if msg.edit_count %}
<details class="compose-edit-history">
<summary>Edited {{ msg.edit_count }} time{% if msg.edit_count != 1 %}s{% endif %}</summary>
<ul>
{% for edit in msg.edit_history %}
<li>
{% if edit.edited_display %}{{ edit.edited_display }}{% else %}Unknown time{% endif %}
{% if edit.actor %} · {{ edit.actor }}{% endif %}
{% if edit.source_service %} · {{ edit.source_service|upper }}{% endif %}
<div class="compose-edit-diff">
<span class="compose-edit-old">{{ edit.previous_text|default:"(empty)" }}</span>
<span class="compose-edit-arrow"></span>
<span class="compose-edit-new">{{ edit.new_text|default:"(empty)" }}</span>
</div>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% if capability_reactions %}
<div class="compose-reaction-actions" data-message-id="{{ msg.id }}">
<button type="button" class="compose-react-btn" data-emoji="👍" title="React with thumbs up">👍</button>
<button type="button" class="compose-react-btn" data-emoji="❤️" title="React with heart">❤️</button>
@@ -495,6 +557,12 @@
{% endif %}
<p class="compose-msg-meta">
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
{% if msg.is_edited %}
<span class="compose-msg-flag is-edited" title="Message edited{% if msg.last_edit_display %} at {{ msg.last_edit_display }}{% endif %}">edited</span>
{% endif %}
{% if msg.is_deleted %}
<span class="compose-msg-flag is-deleted" title="Deleted{% if msg.deleted_display %} at {{ msg.deleted_display }}{% endif %}{% if msg.deleted_actor %} by {{ msg.deleted_actor }}{% endif %}">deleted</span>
{% endif %}
{% if msg.read_ts %}
<span
class="compose-ticks js-receipt-trigger"
@@ -561,8 +629,11 @@
<input type="hidden" name="failsafe_confirm" value="0">
<div class="compose-send-safety">
<label class="checkbox is-size-7">
<input type="checkbox" class="manual-confirm"> Confirm Send
<input type="checkbox" class="manual-confirm"{% if not capability_send %} disabled{% endif %}> Confirm Send
</label>
{% if not capability_send %}
<p class="help is-size-7 has-text-grey">Send disabled: {{ capability_send_reason }}</p>
{% endif %}
</div>
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
<span class="compose-reply-banner-label">Replying to:</span>
@@ -576,7 +647,7 @@
name="text"
rows="1"
placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea>
<button class="button is-link is-light compose-send-btn" type="submit" disabled>
<button class="button is-link is-light compose-send-btn" type="submit" disabled{% if not capability_send %} title="{{ capability_send_reason }}"{% endif %}>
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>Send</span>
</button>
@@ -605,6 +676,134 @@
padding: 0.65rem;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.7), rgba(255, 255, 255, 0.98));
}
#{{ panel_id }} .compose-history-nav {
margin-top: 0.45rem;
display: inline-flex;
gap: 0.35rem;
}
#{{ panel_id }} .compose-history-tab {
border: 1px solid rgba(38, 68, 111, 0.24);
background: #f3f7fc;
color: #2b4364;
border-radius: 999px;
padding: 0.2rem 0.58rem;
font-size: 0.68rem;
font-weight: 600;
line-height: 1.1;
cursor: pointer;
}
#{{ panel_id }} .compose-history-tab.is-active {
background: #2b4f7a;
color: #fff;
border-color: #2b4f7a;
}
#{{ panel_id }} .compose-history-count {
margin-left: 0.22rem;
display: inline-block;
min-width: 1.05rem;
text-align: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.35);
font-size: 0.62rem;
padding: 0.03rem 0.24rem;
}
#{{ panel_id }} .compose-deleted-pane {
margin-top: 0.55rem;
margin-bottom: 0.55rem;
min-height: 8rem;
max-height: 46vh;
overflow-y: auto;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
padding: 0.6rem;
background: linear-gradient(180deg, rgba(253, 248, 247, 0.85), rgba(255, 255, 255, 0.98));
}
#{{ panel_id }} .compose-deleted-pane.is-hidden {
display: none;
}
#{{ panel_id }} .compose-deleted-item {
border: 1px solid rgba(181, 96, 80, 0.2);
border-radius: 8px;
padding: 0.4rem 0.5rem;
margin-bottom: 0.4rem;
background: rgba(255, 248, 247, 0.98);
}
#{{ panel_id }} .compose-deleted-item:last-child {
margin-bottom: 0;
}
#{{ panel_id }} .compose-deleted-meta {
display: flex;
flex-wrap: wrap;
gap: 0.28rem;
font-size: 0.65rem;
color: #7b4c42;
margin-bottom: 0.18rem;
}
#{{ panel_id }} .compose-deleted-preview {
margin: 0;
font-size: 0.71rem;
color: #4b3b38;
white-space: pre-wrap;
word-break: break-word;
}
#{{ panel_id }} .compose-deleted-jump {
margin-top: 0.3rem;
}
#{{ panel_id }} .compose-msg-flag {
display: inline-block;
margin-left: 0.3rem;
border-radius: 999px;
padding: 0.03rem 0.34rem;
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
#{{ panel_id }} .compose-msg-flag.is-edited {
color: #7d5010;
background: rgba(255, 241, 214, 0.95);
border: 1px solid rgba(169, 115, 31, 0.28);
}
#{{ panel_id }} .compose-msg-flag.is-deleted {
color: #842f2f;
background: rgba(255, 228, 228, 0.95);
border: 1px solid rgba(173, 52, 52, 0.28);
}
#{{ panel_id }} .compose-edit-history {
margin-top: 0.28rem;
border-radius: 8px;
border: 1px solid rgba(124, 102, 63, 0.25);
background: rgba(255, 252, 245, 0.96);
padding: 0.24rem 0.4rem;
font-size: 0.64rem;
}
#{{ panel_id }} .compose-edit-history summary {
cursor: pointer;
color: #7a5a22;
font-weight: 600;
}
#{{ panel_id }} .compose-edit-history ul {
margin: 0.25rem 0 0;
padding-left: 1.05rem;
}
#{{ panel_id }} .compose-edit-diff {
margin-top: 0.08rem;
display: flex;
gap: 0.22rem;
align-items: baseline;
}
#{{ panel_id }} .compose-edit-old {
color: #6e6a66;
text-decoration: line-through;
}
#{{ panel_id }} .compose-edit-new {
color: #2f4f78;
font-weight: 600;
}
#{{ panel_id }} .compose-row.is-deleted .compose-bubble {
border-style: dashed;
opacity: 0.96;
}
#{{ panel_id }} .compose-availability-lane {
margin-top: 0.42rem;
display: flex;
@@ -1932,6 +2131,12 @@
const exportClear = document.getElementById(panelId + "-export-clear");
const exportBuffer = document.getElementById(panelId + "-export-buffer");
const availabilityLane = document.getElementById(panelId + "-availability");
const availabilitySummaryNode = document.getElementById(panelId + "-availability-summary");
const deletedPane = document.getElementById(panelId + "-deleted");
const deletedList = document.getElementById(panelId + "-deleted-list");
const deletedEmpty = document.getElementById(panelId + "-deleted-empty");
const deletedCountNode = document.getElementById(panelId + "-deleted-count");
const historyTabs = Array.from(panel.querySelectorAll(".compose-history-tab"));
const csrfToken = "{{ csrf_token }}";
if (lightbox && lightbox.parentElement !== document.body) {
document.body.appendChild(lightbox);
@@ -1976,6 +2181,7 @@
rangeStartId: "",
rangeEndId: "",
rangeMode: "inside",
historyView: "thread",
};
window.giaComposePanels[panelId] = panelState;
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
@@ -2014,6 +2220,13 @@
const parsed = parseInt(value || "0", 10);
return Number.isFinite(parsed) ? parsed : 0;
};
const parseJsonSafe = function (value, fallback) {
try {
return JSON.parse(String(value || ""));
} catch (_err) {
return fallback;
}
};
const minuteBucketFromTs = function (tsValue) {
const ts = toInt(tsValue);
@@ -2848,9 +3061,9 @@
const QUICK_REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
const supportsReactions = function () {
const service = String(thread.dataset.service || "").trim().toLowerCase();
const reactUrl = String(thread.dataset.reactUrl || "").trim();
return !!reactUrl && (service === "signal" || service === "whatsapp");
const capability = String(thread.dataset.capabilityReactions || "").trim() === "1";
return !!reactUrl && capability;
};
const reactionActorKeyForService = function (service) {
const prefix = String(thread.dataset.reactionActorPrefix || "web::");
@@ -2977,6 +3190,46 @@
bar.appendChild(menu);
return bar;
};
const renderEditHistoryDetails = function (bubble, msg) {
if (!bubble) {
return;
}
const rows = Array.isArray(msg && msg.edit_history) ? msg.edit_history : [];
if (!rows.length) {
return;
}
const details = document.createElement("details");
details.className = "compose-edit-history";
const summary = document.createElement("summary");
summary.textContent = "Edited " + rows.length + (rows.length === 1 ? " time" : " times");
details.appendChild(summary);
const list = document.createElement("ul");
rows.forEach(function (entry) {
const li = document.createElement("li");
const editedDisplay = String((entry && entry.edited_display) || "").trim() || "Unknown time";
const actor = String((entry && entry.actor) || "").trim();
const source = String((entry && entry.source_service) || "").trim();
li.textContent = editedDisplay + (actor ? (" · " + actor) : "") + (source ? (" · " + source.toUpperCase()) : "");
const diff = document.createElement("div");
diff.className = "compose-edit-diff";
const oldNode = document.createElement("span");
oldNode.className = "compose-edit-old";
oldNode.textContent = String((entry && entry.previous_text) || "(empty)");
const arrow = document.createElement("span");
arrow.className = "compose-edit-arrow";
arrow.textContent = "->";
const newNode = document.createElement("span");
newNode.className = "compose-edit-new";
newNode.textContent = String((entry && entry.new_text) || "(empty)");
diff.appendChild(oldNode);
diff.appendChild(arrow);
diff.appendChild(newNode);
li.appendChild(diff);
list.appendChild(li);
});
details.appendChild(list);
bubble.appendChild(details);
};
const appendBubble = function (msg) {
const messageId = String(msg && msg.id ? msg.id : "").trim();
@@ -2991,6 +3244,9 @@
const row = document.createElement("div");
const outgoing = !!msg.outgoing;
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
if (msg.is_deleted) {
row.classList.add("is-deleted");
}
row.dataset.ts = String(msg.ts || 0);
row.dataset.minute = minuteBucketFromTs(msg.ts || 0);
row.dataset.replySnippet = normalizeSnippet(
@@ -3003,6 +3259,20 @@
row.dataset.sourceLabel = String(msg.source_label || "");
row.dataset.sourceMessageId = String(msg.source_message_id || "");
row.dataset.direction = outgoing ? "outgoing" : "incoming";
row.dataset.isDeleted = msg.is_deleted ? "1" : "0";
row.dataset.deletedTs = String(msg.deleted_ts || 0);
row.dataset.deletedDisplay = String(msg.deleted_display || "");
row.dataset.deletedActor = String(msg.deleted_actor || "");
row.dataset.deletedSource = String(msg.deleted_source_service || "");
row.dataset.editCount = String(msg.edit_count || 0);
try {
row.dataset.editHistory = JSON.stringify(
Array.isArray(msg.edit_history) ? msg.edit_history : []
);
} catch (_err) {
row.dataset.editHistory = "[]";
}
row.dataset.rawText = String(msg.text || "");
if (msg.reply_to_id) {
row.dataset.replyToId = String(msg.reply_to_id || "");
}
@@ -3078,6 +3348,7 @@
fallback.textContent = "(no text)";
bubble.appendChild(fallback);
}
renderEditHistoryDetails(bubble, msg);
const reactionBar = buildReactionActions(messageId);
if (reactionBar) {
bubble.appendChild(reactionBar);
@@ -3094,6 +3365,22 @@
metaText += " · " + String(msg.author);
}
meta.textContent = metaText;
if (msg.is_edited) {
const editedFlag = document.createElement("span");
editedFlag.className = "compose-msg-flag is-edited";
editedFlag.title = "Message edited" + (msg.last_edit_display ? (" at " + String(msg.last_edit_display)) : "");
editedFlag.textContent = "edited";
meta.appendChild(editedFlag);
}
if (msg.is_deleted) {
const deletedFlag = document.createElement("span");
deletedFlag.className = "compose-msg-flag is-deleted";
deletedFlag.title = "Deleted"
+ (msg.deleted_display ? (" at " + String(msg.deleted_display)) : "")
+ (msg.deleted_actor ? (" by " + String(msg.deleted_actor)) : "");
deletedFlag.textContent = "deleted";
meta.appendChild(deletedFlag);
}
// Render delivery/read ticks and a small time label when available.
if (msg.read_ts) {
const tickWrap = document.createElement("span");
@@ -3164,6 +3451,7 @@
wireImageFallbacks(row);
bindReplyReferences(row);
updateGlanceFromMessage(msg);
renderDeletedList();
};
// Receipt popover (similar to contact info popover)
@@ -3444,6 +3732,69 @@
availabilityLane.classList.remove("is-hidden");
};
const renderAvailabilitySummary = function (summary, slices) {
if (!availabilitySummaryNode) {
return;
}
const rows = Array.isArray(slices) ? slices : [];
let source = (
summary && typeof summary === "object"
) ? Object.assign({}, summary) : {};
if (!source.state && rows.length > 0) {
const newest = rows.reduce(function (best, item) {
if (!best) return item;
return Number(item.end_ts || 0) > Number(best.end_ts || 0) ? item : best;
}, null);
if (newest) {
source.state = String(newest.state || "unknown").toLowerCase();
source.state_label = source.state.charAt(0).toUpperCase() + source.state.slice(1);
source.service = String(newest.service || "").toLowerCase();
source.confidence = Number(newest.confidence_end || 0);
source.source_kind = String(
((newest.payload && newest.payload.inferred_from) || (newest.payload && newest.payload.extended_by) || "")
).trim();
source.ts = Number(newest.end_ts || 0);
source.ts_label = source.ts > 0 ? new Date(source.ts).toLocaleTimeString() : "";
}
}
if (!source.state) {
availabilitySummaryNode.classList.add("is-hidden");
availabilitySummaryNode.innerHTML = "";
return;
}
const state = String(source.state || "unknown").toLowerCase();
const service = String(source.service || "").toUpperCase();
const stateTag = document.createElement("span");
stateTag.className = "tag is-light";
if (state === "available") stateTag.classList.add("is-success");
if (state === "fading") stateTag.classList.add("is-warning");
if (state === "unavailable") stateTag.classList.add("is-danger");
stateTag.textContent = String(source.state_label || (state.charAt(0).toUpperCase() + state.slice(1)));
const serviceTag = document.createElement("span");
serviceTag.className = "tag is-light";
serviceTag.textContent = service || "-";
availabilitySummaryNode.innerHTML = "";
availabilitySummaryNode.appendChild(stateTag);
availabilitySummaryNode.appendChild(serviceTag);
if (source.ts_label) {
const tsTag = document.createElement("span");
tsTag.className = "tag is-light";
tsTag.textContent = "Updated " + String(source.ts_label);
availabilitySummaryNode.appendChild(tsTag);
}
if (source.is_cross_service) {
const fallbackTag = document.createElement("span");
fallbackTag.className = "tag is-light";
fallbackTag.textContent = "Cross-service fallback";
availabilitySummaryNode.appendChild(fallbackTag);
}
availabilitySummaryNode.classList.remove("is-hidden");
};
const applyTyping = function (typingPayload) {
if (!typingNode || !typingPayload || typeof typingPayload !== "object") {
return;
@@ -3481,6 +3832,9 @@
if (payload.availability_slices) {
renderAvailabilitySlices(payload.availability_slices);
}
if (payload.availability_summary || payload.availability_slices) {
renderAvailabilitySummary(payload.availability_summary, payload.availability_slices);
}
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, toInt(payload.last_ts));
thread.dataset.lastTs = String(lastTs);
@@ -3521,6 +3875,9 @@
if (payload.availability_slices) {
renderAvailabilitySlices(payload.availability_slices);
}
if (payload.availability_summary || payload.availability_slices) {
renderAvailabilitySummary(payload.availability_summary, payload.availability_slices);
}
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, toInt(payload.last_ts));
thread.dataset.lastTs = String(lastTs);
@@ -3544,6 +3901,7 @@
const armInput = form.querySelector("input[name='failsafe_arm']");
const confirmInput = form.querySelector("input[name='failsafe_confirm']");
const sendButton = form.querySelector(".compose-send-btn");
const sendCapabilityEnabled = {{ capability_send|yesno:"true,false" }};
const updateManualSafety = function () {
const confirm = !!(manualConfirm && manualConfirm.checked);
if (armInput) {
@@ -3553,7 +3911,7 @@
confirmInput.value = confirm ? "1" : "0";
}
if (sendButton) {
sendButton.disabled = !confirm;
sendButton.disabled = (!confirm) || (!sendCapabilityEnabled);
}
};
if (manualConfirm) {
@@ -3561,14 +3919,21 @@
}
updateManualSafety();
try {
const initialTyping = JSON.parse("{{ typing_state_json|escapejs }}");
applyTyping(initialTyping);
try {
const initialSlices = JSON.parse(String((availabilityLane && availabilityLane.dataset.slices) || "[]"));
renderAvailabilitySlices(initialSlices);
} catch (err) {
renderAvailabilitySlices([]);
}
const initialTyping = JSON.parse("{{ typing_state_json|escapejs }}");
applyTyping(initialTyping);
try {
const initialSlices = JSON.parse(
String((availabilityLane && availabilityLane.dataset.slices) || "[]")
);
const initialSummary = JSON.parse(
String((availabilitySummaryNode && availabilitySummaryNode.dataset.summary) || "{}")
);
renderAvailabilitySlices(initialSlices);
renderAvailabilitySummary(initialSummary, initialSlices);
} catch (err) {
renderAvailabilitySlices([]);
renderAvailabilitySummary({}, []);
}
} catch (err) {
// Ignore invalid initial typing state payload.
}
@@ -3692,6 +4057,120 @@
const allMessageRows = function () {
return Array.from(thread.querySelectorAll(".compose-row[data-message-id]"));
};
const readRowEditHistory = function (row) {
if (!row || !row.dataset) {
return [];
}
const parsed = parseJsonSafe(row.dataset.editHistory || "[]", []);
return Array.isArray(parsed) ? parsed : [];
};
const renderDeletedList = function () {
if (!deletedList || !deletedEmpty) {
return;
}
const deletedRows = allMessageRows()
.filter(function (row) {
return String(row.dataset.isDeleted || "0") === "1";
})
.sort(function (a, b) {
return toInt(b.dataset.deletedTs || 0) - toInt(a.dataset.deletedTs || 0);
});
if (deletedCountNode) {
deletedCountNode.textContent = String(deletedRows.length);
}
deletedList.innerHTML = "";
if (!deletedRows.length) {
deletedEmpty.classList.remove("is-hidden");
return;
}
deletedEmpty.classList.add("is-hidden");
deletedRows.forEach(function (row) {
const messageId = String(row.dataset.messageId || "").trim();
const deletedTs = String(row.dataset.deletedDisplay || "").trim() || String(row.dataset.displayTs || "").trim() || "-";
const deletedActor = String(row.dataset.deletedActor || "").trim() || "unknown";
const deletedSource = String(row.dataset.deletedSource || "").trim() || "unknown";
const preview = normalizeSnippet(row.dataset.rawText || row.dataset.replySnippet || "(message deleted)");
const edits = readRowEditHistory(row);
const card = document.createElement("article");
card.className = "compose-deleted-item";
const meta = document.createElement("p");
meta.className = "compose-deleted-meta";
meta.textContent = "Deleted " + deletedTs + " by " + deletedActor + " via " + deletedSource.toUpperCase();
card.appendChild(meta);
const text = document.createElement("p");
text.className = "compose-deleted-preview";
text.textContent = preview;
card.appendChild(text);
if (edits.length) {
const details = document.createElement("details");
details.className = "compose-edit-history";
const summary = document.createElement("summary");
summary.textContent = "Edit history (" + edits.length + ")";
details.appendChild(summary);
const list = document.createElement("ul");
edits.forEach(function (entry) {
const li = document.createElement("li");
const editedDisplay = String((entry && entry.edited_display) || "").trim() || "Unknown time";
const actor = String((entry && entry.actor) || "").trim();
const source = String((entry && entry.source_service) || "").trim();
const oldText = String((entry && entry.previous_text) || "(empty)");
const newText = String((entry && entry.new_text) || "(empty)");
li.textContent = editedDisplay + (actor ? (" · " + actor) : "") + (source ? (" · " + source.toUpperCase()) : "");
const diff = document.createElement("div");
diff.className = "compose-edit-diff";
const oldNode = document.createElement("span");
oldNode.className = "compose-edit-old";
oldNode.textContent = oldText;
const arrow = document.createElement("span");
arrow.className = "compose-edit-arrow";
arrow.textContent = "->";
const newNode = document.createElement("span");
newNode.className = "compose-edit-new";
newNode.textContent = newText;
diff.appendChild(oldNode);
diff.appendChild(arrow);
diff.appendChild(newNode);
li.appendChild(diff);
list.appendChild(li);
});
details.appendChild(list);
card.appendChild(details);
}
const jump = document.createElement("button");
jump.type = "button";
jump.className = "button is-light is-small compose-deleted-jump";
jump.dataset.targetId = messageId;
jump.textContent = "Jump to message";
card.appendChild(jump);
deletedList.appendChild(card);
});
};
const switchHistoryView = function (viewName) {
const target = String(viewName || "thread").trim().toLowerCase() === "deleted"
? "deleted"
: "thread";
panelState.historyView = target;
historyTabs.forEach(function (tab) {
const active = String(tab.dataset.target || "") === target;
tab.classList.toggle("is-active", active);
tab.setAttribute("aria-selected", active ? "true" : "false");
});
if (target === "deleted") {
if (thread) {
thread.classList.add("is-hidden");
}
if (deletedPane) {
deletedPane.classList.remove("is-hidden");
}
} else {
if (thread) {
thread.classList.remove("is-hidden");
}
if (deletedPane) {
deletedPane.classList.add("is-hidden");
}
}
};
const selectedRangeRows = function () {
const rows = allMessageRows();
@@ -4172,7 +4651,33 @@
});
});
};
historyTabs.forEach(function (tab) {
tab.addEventListener("click", function () {
switchHistoryView(String(tab.dataset.target || "thread"));
});
});
if (deletedList) {
deletedList.addEventListener("click", function (ev) {
const jumpBtn = ev.target.closest && ev.target.closest(".compose-deleted-jump");
if (!jumpBtn) {
return;
}
const targetId = String(jumpBtn.dataset.targetId || "").trim();
if (!targetId) {
return;
}
switchHistoryView("thread");
const row = rowByMessageId(targetId);
if (!row) {
return;
}
row.scrollIntoView({ behavior: "smooth", block: "center" });
flashReplyTarget(row);
});
}
bindReplyReferences(panel);
renderDeletedList();
switchHistoryView(panelState.historyView);
initExportUi();
if (replyClearBtn) {
replyClearBtn.addEventListener("click", function () {
@@ -4264,6 +4769,16 @@
panelState.websocketReady = false;
hideAllCards();
thread.innerHTML = '<p class="compose-empty">Loading messages...</p>';
if (deletedList) {
deletedList.innerHTML = "";
}
if (deletedEmpty) {
deletedEmpty.classList.remove("is-hidden");
}
if (deletedCountNode) {
deletedCountNode.textContent = "0";
}
switchHistoryView("thread");
lastTs = 0;
thread.dataset.lastTs = "0";
panelState.seenMessageIds = new Set();