Implement plans

This commit is contained in:
2026-03-04 02:19:22 +00:00
parent 34ee49410d
commit 0718a06c19
31 changed files with 3987 additions and 181 deletions

View File

@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Codex Status</h1>
<p class="subtitle is-6">Global per-user Codex task-sync status, runs, and approvals.</p>
<article class="box">
<div class="codex-inline-stats">
<span><strong>Provider</strong> codex_cli</span>
<span><strong>Health</strong> <span class="{% if health and health.ok %}has-text-success{% else %}has-text-danger{% endif %}">{% if health and health.ok %}online{% else %}offline{% endif %}</span></span>
<span><strong>Pending</strong> {{ queue_counts.pending }}</span>
<span><strong>Waiting Approval</strong> {{ queue_counts.waiting_approval }}</span>
</div>
{% if health and health.error %}
<p class="help">Healthcheck error: <code>{{ health.error }}</code></p>
{% endif %}
<p class="help">Config snapshot: command=<code>{{ provider_settings.command }}</code>, workspace=<code>{{ provider_settings.workspace_root|default:"-" }}</code>, profile=<code>{{ provider_settings.default_profile|default:"-" }}</code>, instance=<code>{{ provider_settings.instance_label }}</code>, approver=<code>{{ provider_settings.approver_service }} {{ provider_settings.approver_identifier }}</code>.</p>
<p class="help"><a href="{% url 'tasks_settings' %}">Edit in Task Settings</a>.</p>
</article>
<article class="box">
<h2 class="title is-6">Run Filters</h2>
<form method="get">
<div class="columns is-multiline">
<div class="column is-2">
<label class="label is-size-7">Status</label>
<input class="input is-small" name="status" value="{{ filters.status }}" placeholder="ok/failed/...">
</div>
<div class="column is-2">
<label class="label is-size-7">Service</label>
<input class="input is-small" name="service" value="{{ filters.service }}" placeholder="signal">
</div>
<div class="column is-3">
<label class="label is-size-7">Channel</label>
<input class="input is-small" name="channel" value="{{ filters.channel }}" placeholder="identifier">
</div>
<div class="column is-3">
<label class="label is-size-7">Project</label>
<div class="select is-small is-fullwidth">
<select name="project">
<option value="">All</option>
{% for row in projects %}
<option value="{{ row.id }}" {% if filters.project == row.id|stringformat:"s" %}selected{% endif %}>{{ row.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-size-7">Date From</label>
<input class="input is-small" type="date" name="date_from" value="{{ filters.date_from }}">
</div>
</div>
<button class="button is-small is-link is-light" type="submit">Apply</button>
</form>
</article>
<article class="box">
<h2 class="title is-6">Runs</h2>
<table class="table is-fullwidth is-size-7 is-striped">
<thead><tr><th>When</th><th>Status</th><th>Service/Channel</th><th>Project</th><th>Task</th><th>Summary</th><th>Files</th><th>Links</th></tr></thead>
<tbody>
{% for run in runs %}
<tr>
<td>{{ run.created_at }}</td>
<td>{{ run.status }}</td>
<td>{{ run.source_service }} · <code>{{ run.source_channel }}</code></td>
<td>{{ run.project.name|default:"-" }}</td>
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td>{{ run.result_payload.summary|default:"-" }}</td>
<td>{{ run.result_payload.files_modified_count|default:"0" }}</td>
<td>
<details>
<summary>Details</summary>
<p><strong>Request</strong></p>
<pre>{{ run.request_payload }}</pre>
<p><strong>Result</strong></p>
<pre>{{ run.result_payload }}</pre>
<p><strong>Error</strong> {{ run.error|default:"-" }}</p>
</details>
</td>
</tr>
{% empty %}
<tr><td colspan="8">No runs.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Permission Queue</h2>
<table class="table is-fullwidth is-size-7 is-striped">
<thead><tr><th>Requested</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Permissions</th><th>Run</th><th>Task</th><th>Actions</th></tr></thead>
<tbody>
{% for row in permission_requests %}
<tr>
<td>{{ row.requested_at }}</td>
<td><code>{{ row.approval_key }}</code></td>
<td>{{ row.status }}</td>
<td>{{ row.summary|default:"-" }}</td>
<td><pre>{{ row.requested_permissions }}</pre></td>
<td><code>{{ row.codex_run_id }}</code></td>
<td>{% if row.codex_run.task %}<a href="{% url 'tasks_task' task_id=row.codex_run.task.id %}">#{{ row.codex_run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td>
{% if row.status == 'pending' %}
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="request_id" value="{{ row.id }}">
<input type="hidden" name="decision" value="approve">
<button class="button is-small is-success is-light" type="submit">Approve</button>
</form>
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="request_id" value="{{ row.id }}">
<input type="hidden" name="decision" value="deny">
<button class="button is-small is-danger is-light" type="submit">Deny</button>
</form>
{% else %}
-
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="8">No permission requests.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
<style>
.codex-inline-stats {
display: flex;
gap: 0.95rem;
flex-wrap: wrap;
font-size: 0.92rem;
margin-bottom: 0.25rem;
}
.codex-inline-stats span {
white-space: nowrap;
}
</style>
{% endblock %}

View File

@@ -38,7 +38,7 @@
</div>
<div class="column is-4">
<label class="label is-size-7" for="create_trigger_token">Primary Trigger Token</label>
<input id="create_trigger_token" class="input is-small" name="trigger_token" value="#bp#" readonly>
<input id="create_trigger_token" class="input is-small" name="trigger_token" value=".bp" readonly>
</div>
</div>
<label class="label is-size-7" for="create_template_text">BP Template (used only by <code>bp</code> in AI mode)</label>
@@ -446,4 +446,30 @@
border-top: 1px solid #dbdbdb;
}
</style>
<script>
(function () {
const commandSelect = document.getElementById("create_command_slug");
const nameInput = document.getElementById("create_name");
const triggerInput = document.getElementById("create_trigger_token");
if (!commandSelect || !nameInput || !triggerInput) {
return;
}
const applyDefaults = function () {
const slug = String(commandSelect.value || "").trim().toLowerCase();
if (slug === "codex") {
triggerInput.value = ".codex";
if (!nameInput.value || nameInput.value === "Business Plan") {
nameInput.value = "Codex";
}
return;
}
triggerInput.value = ".bp";
if (!nameInput.value || nameInput.value === "Codex") {
nameInput.value = "Business Plan";
}
};
commandSelect.addEventListener("change", applyDefaults);
applyDefaults();
})();
</script>
{% endblock %}

View File

@@ -9,7 +9,15 @@
· Source message <code>{{ task.origin_message_id }}</code>
{% endif %}
</p>
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a></div>
<div class="buttons">
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ task.id }}">
<input type="hidden" name="next" value="{% url 'tasks_task' task_id=task.id %}">
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
</form>
</div>
<article class="box">
<h2 class="title is-6">Events</h2>
<table class="table is-fullwidth is-size-7">
@@ -58,6 +66,44 @@
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Codex Runs</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Status</th><th>Summary</th><th>Files</th><th>Error</th></tr></thead>
<tbody>
{% for row in codex_runs %}
<tr>
<td>{{ row.updated_at }}</td>
<td>{{ row.status }}</td>
<td>{{ row.result_payload.summary|default:"-" }}</td>
<td>{{ row.result_payload.files_modified_count|default:"0" }}</td>
<td>{{ row.error|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No Codex runs.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Permission Requests</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Resolved</th></tr></thead>
<tbody>
{% for row in permission_requests %}
<tr>
<td>{{ row.requested_at }}</td>
<td><code>{{ row.approval_key }}</code></td>
<td>{{ row.status }}</td>
<td>{{ row.summary|default:"-" }}</td>
<td>{{ row.resolved_at|default:"-" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No permission requests.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div></section>
<style>
.task-event-payload {

View File

@@ -5,7 +5,7 @@
<h1 class="title is-4">Tasks</h1>
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
<div class="buttons" style="margin-bottom: 0.75rem;">
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}">Task Settings</a>
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}{% if scope.person_id or scope.service or scope.identifier %}?{% if scope.person_id %}person={{ scope.person_id|urlencode }}{% endif %}{% if scope.service %}{% if scope.person_id %}&{% endif %}service={{ scope.service|urlencode }}{% endif %}{% if scope.identifier %}{% if scope.person_id or scope.service %}&{% endif %}identifier={{ scope.identifier|urlencode }}{% endif %}{% endif %}">Task Settings</a>
</div>
<div class="columns is-variable is-5">
<div class="column is-4">
@@ -134,7 +134,7 @@
<article class="box">
<h2 class="title is-6">Recent Derived Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th></th></tr></thead>
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
@@ -148,7 +148,15 @@
</td>
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
<td>{{ row.status_snapshot }}</td>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
<td>
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="6">No derived tasks yet.</td></tr>

View File

@@ -63,11 +63,26 @@
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_identifier %}
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
{% if row.creator_compose_href %}
<div><a class="is-size-7" href="{{ row.creator_compose_href }}">Compose</a></div>
{% endif %}
</td>
<td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</td>
<td>
<form method="post" class="is-flex" style="gap: 0.35rem; align-items: center;">
{% csrf_token %}
<input type="hidden" name="action" value="task_set_epic">
<input type="hidden" name="task_id" value="{{ row.id }}">
<div class="select is-small">
<select name="epic_id">
<option value="">No epic</option>
{% for epic in epics %}
<option value="{{ epic.id }}" {% if row.epic_id == epic.id %}selected{% endif %}>{{ epic.name }}</option>
{% endfor %}
</select>
</div>
<button class="button is-small is-light" type="submit">Set</button>
</form>
</td>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
</tr>
{% empty %}

View File

@@ -335,7 +335,7 @@
<input type="hidden" name="provider" value="codex_cli">
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if codex_provider_config and codex_provider_config.enabled %}checked{% endif %}> Enable Codex CLI provider</label>
<p class="help">Codex task-sync runs in a dedicated worker (<code>python manage.py codex_worker</code>).</p>
<p class="help">This provider syncs task updates to Codex; it does not mirror whole chat threads in this phase.</p>
<p class="help">This provider config is global per-user and shared across all projects/chats. This phase is task-sync only (no full transcript mirroring by default).</p>
<div class="field" style="margin-top:0.5rem;">
<label class="label is-size-7">Command</label>
<input class="input is-small" name="command" value="{{ codex_provider_settings.command }}" placeholder="codex">
@@ -352,10 +352,67 @@
<label class="label is-size-7">Timeout Seconds</label>
<input class="input is-small" type="number" min="1" name="timeout_seconds" value="{{ codex_provider_settings.timeout_seconds }}">
</div>
<div class="field">
<label class="label is-size-7">Instance Label</label>
<input class="input is-small" name="instance_label" value="{{ codex_provider_settings.instance_label }}" placeholder="default">
</div>
<div class="field">
<label class="label is-size-7">Approver Service</label>
<input class="input is-small" name="approver_service" value="{{ codex_provider_settings.approver_service }}" placeholder="signal">
</div>
<div class="field">
<label class="label is-size-7">Approver Identifier</label>
<input class="input is-small" name="approver_identifier" value="{{ codex_provider_settings.approver_identifier }}" placeholder="+15550000001">
</div>
<div style="margin-top:0.5rem;">
<button class="button is-small is-link is-light" type="submit">Save Codex Provider</button>
<a class="button is-small is-light" href="{% url 'codex_settings' %}">Open Codex Status</a>
</div>
</form>
<hr>
<article class="box" style="margin-top:0.5rem;">
<h4 class="title is-7">Codex Compact Summary</h4>
<p class="help">
Health:
{% if codex_compact_summary.healthcheck_ok %}
<span class="tag is-success is-light">online</span>
{% else %}
<span class="tag is-danger is-light">offline</span>
{% endif %}
{% if codex_compact_summary.healthcheck_error %}
<code>{{ codex_compact_summary.healthcheck_error }}</code>
{% endif %}
</p>
<p class="help">
Worker heartbeat:
{% if codex_compact_summary.worker_heartbeat_at %}
{{ codex_compact_summary.worker_heartbeat_at }} ({{ codex_compact_summary.worker_heartbeat_age }})
{% else %}
no worker activity yet
{% endif %}
</p>
<div class="tags">
<span class="tag is-light">pending {{ codex_compact_summary.queue_counts.pending }}</span>
<span class="tag is-warning is-light">waiting_approval {{ codex_compact_summary.queue_counts.waiting_approval }}</span>
<span class="tag is-danger is-light">failed {{ codex_compact_summary.queue_counts.failed }}</span>
<span class="tag is-success is-light">ok {{ codex_compact_summary.queue_counts.ok }}</span>
</div>
<table class="table is-fullwidth is-size-7 is-striped" style="margin-top:0.5rem;">
<thead><tr><th>When</th><th>Status</th><th>Task</th><th>Summary</th></tr></thead>
<tbody>
{% for run in codex_compact_summary.recent_runs %}
<tr>
<td>{{ run.created_at }}</td>
<td>{{ run.status }}</td>
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td>{{ run.result_payload.summary|default:"-" }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
</section>
</div>

View File

@@ -396,6 +396,8 @@
data-summary-url="{{ compose_summary_url }}"
data-quick-insights-url="{{ compose_quick_insights_url }}"
data-history-sync-url="{{ compose_history_sync_url }}"
data-react-url="{% url 'compose_react' %}"
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 }}">
@@ -458,11 +460,33 @@
{% else %}
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
{% endif %}
{% if service == "signal" or service == "whatsapp" %}
<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>
<button type="button" class="compose-react-btn" data-emoji="😂" title="React with laugh">😂</button>
<button type="button" class="compose-react-btn" data-emoji="😮" title="React with surprise">😮</button>
<button type="button" class="compose-react-btn" data-emoji="😢" title="React with sad">😢</button>
<button type="button" class="compose-react-btn" data-emoji="😡" title="React with angry">😡</button>
<button type="button" class="compose-react-menu-toggle" title="More reactions" aria-label="More reactions">+</button>
<div class="compose-react-menu is-hidden" aria-label="Emoji reaction picker">
<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>
<button type="button" class="compose-react-btn" data-emoji="😂" title="React with laugh">😂</button>
<button type="button" class="compose-react-btn" data-emoji="😮" title="React with surprise">😮</button>
<button type="button" class="compose-react-btn" data-emoji="😢" title="React with sad">😢</button>
<button type="button" class="compose-react-btn" data-emoji="😡" title="React with angry">😡</button>
</div>
</div>
{% endif %}
{% if msg.reactions %}
<div class="compose-reactions" aria-label="Message reactions">
{% for reaction in msg.reactions %}
<span
class="compose-reaction-chip"
data-emoji="{{ reaction.emoji|escape }}"
data-actor="{{ reaction.actor|default:''|escape }}"
data-source-service="{{ reaction.source_service|default:''|escape }}"
title="{{ reaction.actor|default:'Unknown' }} via {{ reaction.source_service|default:'unknown'|upper }}">
{{ reaction.emoji }}
</span>
@@ -935,6 +959,61 @@
gap: 0.26rem;
margin: 0 0 0.28rem 0;
}
#{{ panel_id }} .compose-reaction-actions {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.22rem;
margin: 0 0 0.32rem 0;
position: relative;
}
#{{ panel_id }} .compose-react-btn,
#{{ panel_id }} .compose-react-menu-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.45rem;
min-width: 1.45rem;
padding: 0 0.34rem;
border-radius: 999px;
border: 1px solid rgba(127, 127, 127, 0.35);
background: rgba(127, 127, 127, 0.12);
color: inherit;
font-size: 0.82rem;
line-height: 1;
cursor: pointer;
}
#{{ panel_id }} .compose-react-menu-toggle {
font-size: 0.78rem;
font-weight: 700;
}
#{{ panel_id }} .compose-react-btn:hover,
#{{ panel_id }} .compose-react-menu-toggle:hover {
background: rgba(127, 127, 127, 0.18);
border-color: rgba(127, 127, 127, 0.5);
}
#{{ panel_id }} .compose-react-btn:focus-visible,
#{{ panel_id }} .compose-react-menu-toggle:focus-visible {
outline: 2px solid rgba(60, 132, 218, 0.8);
outline-offset: 1px;
}
#{{ panel_id }} .compose-react-menu {
position: absolute;
top: calc(100% + 0.2rem);
right: 0;
z-index: 5;
display: flex;
align-items: center;
gap: 0.22rem;
padding: 0.26rem;
border-radius: 999px;
border: 1px solid rgba(127, 127, 127, 0.4);
background: color-mix(in srgb, Canvas 86%, transparent);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
}
#{{ panel_id }} .compose-react-menu.is-hidden {
display: none;
}
#{{ panel_id }} .compose-reaction-chip {
display: inline-flex;
align-items: center;
@@ -2767,6 +2846,138 @@
thread.insertBefore(row, rows[0]);
};
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 reactionActorKeyForService = function (service) {
const prefix = String(thread.dataset.reactionActorPrefix || "web::");
return prefix + String(service || "").trim().toLowerCase();
};
const parseBubbleReactions = function (bubble) {
if (!bubble) {
return [];
}
return Array.from(bubble.querySelectorAll(".compose-reaction-chip")).map(function (chip) {
return {
emoji: String(chip.dataset.emoji || chip.textContent || "").trim(),
actor: String(chip.dataset.actor || "").trim(),
source_service: String(chip.dataset.sourceService || "").trim().toLowerCase(),
};
}).filter(function (row) {
return !!row.emoji;
});
};
const renderBubbleReactions = function (bubble, reactions) {
if (!bubble) {
return;
}
const existingWrap = bubble.querySelector(".compose-reactions");
if (existingWrap) {
existingWrap.remove();
}
const rows = Array.isArray(reactions) ? reactions : [];
if (!rows.length) {
return;
}
const reactionsWrap = document.createElement("div");
reactionsWrap.className = "compose-reactions";
reactionsWrap.setAttribute("aria-label", "Message reactions");
rows.forEach(function (reaction) {
const chip = document.createElement("span");
const emoji = String((reaction && reaction.emoji) || "").trim();
if (!emoji) {
return;
}
const actor = String((reaction && reaction.actor) || "").trim();
const sourceService = String((reaction && reaction.source_service) || "").trim().toLowerCase();
chip.className = "compose-reaction-chip";
chip.textContent = emoji;
chip.dataset.emoji = emoji;
chip.dataset.actor = actor;
chip.dataset.sourceService = sourceService;
chip.title = (actor || "Unknown") + " via " + (sourceService || "unknown").toUpperCase();
reactionsWrap.appendChild(chip);
});
if (reactionsWrap.children.length) {
bubble.appendChild(reactionsWrap);
}
};
const mergeOptimisticReactions = function (rows, emoji, remove, actorKey, sourceService) {
const existing = Array.isArray(rows) ? rows.slice() : [];
const normalizedEmoji = String(emoji || "").trim();
const normalizedActor = String(actorKey || "").trim();
const normalizedService = String(sourceService || "").trim().toLowerCase();
if (!normalizedEmoji) {
return existing;
}
if (remove) {
return existing.filter(function (row) {
return !(
String((row && row.emoji) || "").trim() === normalizedEmoji
&& String((row && row.actor) || "").trim() === normalizedActor
&& String((row && row.source_service) || "").trim().toLowerCase() === normalizedService
);
});
}
const hasMatch = existing.some(function (row) {
return (
String((row && row.emoji) || "").trim() === normalizedEmoji
&& String((row && row.actor) || "").trim() === normalizedActor
&& String((row && row.source_service) || "").trim().toLowerCase() === normalizedService
);
});
if (hasMatch) {
return existing;
}
existing.push({
emoji: normalizedEmoji,
actor: normalizedActor,
source_service: normalizedService,
});
return existing;
};
const buildReactionActions = function (messageId) {
if (!supportsReactions()) {
return null;
}
const bar = document.createElement("div");
bar.className = "compose-reaction-actions";
bar.dataset.messageId = String(messageId || "").trim();
QUICK_REACTION_EMOJIS.forEach(function (emoji) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "compose-react-btn";
btn.dataset.emoji = emoji;
btn.title = "React with " + emoji;
btn.textContent = emoji;
bar.appendChild(btn);
});
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "compose-react-menu-toggle";
toggle.title = "More reactions";
toggle.setAttribute("aria-label", "More reactions");
toggle.textContent = "+";
bar.appendChild(toggle);
const menu = document.createElement("div");
menu.className = "compose-react-menu is-hidden";
menu.setAttribute("aria-label", "Emoji reaction picker");
QUICK_REACTION_EMOJIS.forEach(function (emoji) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "compose-react-btn";
btn.dataset.emoji = emoji;
btn.title = "React with " + emoji;
btn.textContent = emoji;
menu.appendChild(btn);
});
bar.appendChild(menu);
return bar;
};
const appendBubble = function (msg) {
const messageId = String(msg && msg.id ? msg.id : "").trim();
if (messageId) {
@@ -2867,22 +3078,14 @@
fallback.textContent = "(no text)";
bubble.appendChild(fallback);
}
if (Array.isArray(msg.reactions) && msg.reactions.length) {
const reactionsWrap = document.createElement("div");
reactionsWrap.className = "compose-reactions";
reactionsWrap.setAttribute("aria-label", "Message reactions");
msg.reactions.forEach(function (reaction) {
const chip = document.createElement("span");
chip.className = "compose-reaction-chip";
chip.textContent = String(reaction && reaction.emoji ? reaction.emoji : "");
chip.title =
String((reaction && reaction.actor) || "Unknown")
+ " via "
+ String((reaction && reaction.source_service) || "unknown").toUpperCase();
reactionsWrap.appendChild(chip);
});
bubble.appendChild(reactionsWrap);
const reactionBar = buildReactionActions(messageId);
if (reactionBar) {
bubble.appendChild(reactionBar);
}
renderBubbleReactions(
bubble,
Array.isArray(msg.reactions) ? msg.reactions : []
);
const meta = document.createElement("p");
meta.className = "compose-msg-meta";
@@ -3019,6 +3222,88 @@
// Delegate click on tick triggers inside thread
thread.addEventListener("click", function (ev) {
const menuToggleBtn = ev.target.closest && ev.target.closest(".compose-react-menu-toggle");
if (menuToggleBtn) {
const actions = menuToggleBtn.closest(".compose-reaction-actions");
if (!actions) {
return;
}
const menu = actions.querySelector(".compose-react-menu");
if (!menu) {
return;
}
thread.querySelectorAll(".compose-react-menu").forEach(function (node) {
if (node !== menu) {
node.classList.add("is-hidden");
}
});
menu.classList.toggle("is-hidden");
return;
}
const reactBtn = ev.target.closest && ev.target.closest(".compose-react-btn");
if (reactBtn) {
const emoji = String(reactBtn.dataset.emoji || "").trim();
const row = reactBtn.closest(".compose-row");
const bubble = reactBtn.closest(".compose-bubble");
const service = String(thread.dataset.service || "").trim().toLowerCase();
const reactUrl = String(thread.dataset.reactUrl || "").trim();
if (!emoji || !row || !bubble || !reactUrl || !supportsReactions()) {
return;
}
const messageId = String(row.dataset.messageId || "").trim();
if (!messageId) {
return;
}
const actorKey = reactionActorKeyForService(service);
const existingRows = parseBubbleReactions(bubble);
const hasMine = existingRows.some(function (item) {
return (
String((item && item.emoji) || "").trim() === emoji
&& String((item && item.actor) || "").trim() === actorKey
&& String((item && item.source_service) || "").trim().toLowerCase() === service
);
});
const remove = !!hasMine;
const optimisticRows = mergeOptimisticReactions(
existingRows,
emoji,
remove,
actorKey,
service
);
renderBubbleReactions(bubble, optimisticRows);
const actions = reactBtn.closest(".compose-reaction-actions");
if (actions) {
const menu = actions.querySelector(".compose-react-menu");
if (menu) {
menu.classList.add("is-hidden");
}
}
const formData = queryParams();
formData.set("message_id", messageId);
formData.set("emoji", emoji);
formData.set("remove", remove ? "1" : "0");
postFormJson(reactUrl, formData)
.then(function (payload) {
if (!payload || !payload.ok) {
renderBubbleReactions(bubble, existingRows);
setStatus(
String((payload && (payload.error || payload.message)) || "Reaction failed."),
"warning"
);
return;
}
renderBubbleReactions(
bubble,
Array.isArray(payload.reactions) ? payload.reactions : optimisticRows
);
})
.catch(function () {
renderBubbleReactions(bubble, existingRows);
setStatus("Reaction send failed.", "warning");
});
return;
}
const replyBtn = ev.target.closest && ev.target.closest(".compose-reply-btn");
if (replyBtn) {
const row = replyBtn.closest(".compose-row");
@@ -3060,6 +3345,11 @@
// Close receipt popover on outside click / escape
document.addEventListener("click", function (ev) {
if (!ev.target.closest || !ev.target.closest(".compose-reaction-actions")) {
thread.querySelectorAll(".compose-react-menu").forEach(function (node) {
node.classList.add("is-hidden");
});
}
if (receiptPopover.classList.contains('is-hidden')) return;
if (receiptPopover.contains(ev.target)) return;
if (activeReceiptBtn && activeReceiptBtn.contains(ev.target)) return;