Implement plans
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user