feat: structured @-mentions (notify+clickable+hover), distinct links, Ctrl-rebalance sliders
- Mentions now insert a span carrying the user id (handles names with spaces): the sanitizer permits <span class="mention" data-user-card="ULID">, comment and chat notifications resolve by id and only fire for users with access, and mentions render as clickable chips with the shared hover user-card. - Messages composer inserts an atomic, non-editable mention chip so the trailing space no longer collapses while typing. - Links inside chat messages and task comments are underlined/accented so they read as clickable. - Atomization: hold Ctrl/Cmd while dragging an effort slider to rebalance the sibling coefficients proportionally (sum stays ~1.00); added a hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,31 @@ func SanitizeHTML(in string) string {
|
||||
name, attrs := splitTag(raw)
|
||||
name = strings.ToLower(name)
|
||||
|
||||
// @-mention tokens: <span class="mention" data-user-card="ULID">@Name</span>.
|
||||
// Only this exact shape is allowed; any other span is dropped.
|
||||
if name == "span" {
|
||||
if closing {
|
||||
for n := len(stack) - 1; n >= 0; n-- {
|
||||
if stack[n] == "span" {
|
||||
for len(stack) > n {
|
||||
top := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
out.WriteString("</" + top + ">")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
uid := mentionUID(attrs)
|
||||
if uid == "" {
|
||||
continue // drop the opening span, keep its text
|
||||
}
|
||||
out.WriteString(`<span class="mention" data-user-card="` + html.EscapeString(uid) + `">`)
|
||||
stack = append(stack, "span")
|
||||
continue
|
||||
}
|
||||
|
||||
if !allowedTags[name] {
|
||||
continue // drop the tag entirely, keep surrounding text
|
||||
}
|
||||
@@ -104,6 +129,50 @@ func splitTag(raw string) (string, string) {
|
||||
return raw, ""
|
||||
}
|
||||
|
||||
// mentionUID validates a mention span's attributes and returns the referenced
|
||||
// user id (alphanumeric, ULID-shaped), or "" if the span is not a valid
|
||||
// mention.
|
||||
func mentionUID(attrs string) string {
|
||||
low := strings.ToLower(attrs)
|
||||
if !strings.Contains(low, "mention") {
|
||||
return ""
|
||||
}
|
||||
idx := strings.Index(low, "data-user-card")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(attrs[idx+len("data-user-card"):])
|
||||
if !strings.HasPrefix(rest, "=") {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[1:])
|
||||
var val string
|
||||
switch {
|
||||
case strings.HasPrefix(rest, `"`):
|
||||
if e := strings.IndexByte(rest[1:], '"'); e >= 0 {
|
||||
val = rest[1 : 1+e]
|
||||
}
|
||||
case strings.HasPrefix(rest, "'"):
|
||||
if e := strings.IndexByte(rest[1:], '\''); e >= 0 {
|
||||
val = rest[1 : 1+e]
|
||||
}
|
||||
default:
|
||||
val = rest
|
||||
if sp := strings.IndexAny(val, " \t"); sp >= 0 {
|
||||
val = val[:sp]
|
||||
}
|
||||
}
|
||||
if val == "" || len(val) > 32 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range val {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// safeHref extracts href and accepts only http, https, or mailto schemes.
|
||||
func safeHref(attrs string) string {
|
||||
lower := strings.ToLower(attrs)
|
||||
|
||||
+23
-25
@@ -336,7 +336,9 @@ func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
var mentionRe = regexp.MustCompile(`@([\w.+-]+@[\w.-]+\.\w+|\w+)`)
|
||||
// mentionUIDRe pulls user ids out of mention spans the sanitizer emitted:
|
||||
// <span class="mention" data-user-card="ULID">@Name</span>.
|
||||
var mentionUIDRe = regexp.MustCompile(`data-user-card="([0-9A-Za-z]+)"`)
|
||||
|
||||
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
@@ -392,43 +394,39 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
|
||||
}
|
||||
|
||||
// notifyMentions resolves @name / @email tokens against task participants
|
||||
// (§11.3).
|
||||
// notifyMentions notifies users referenced by mention spans in the comment
|
||||
// body, but only those who actually have access to the task (§11.3).
|
||||
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
|
||||
plain := chat.SanitizePlain(body)
|
||||
matches := mentionRe.FindAllStringSubmatch(plain, 10)
|
||||
matches := mentionUIDRe.FindAllStringSubmatch(body, 20)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
// candidate participants
|
||||
ids := map[string]bool{t.ConsultantID: true}
|
||||
ctx := r.Context()
|
||||
// who can see this task: its consultants, assignee, claimers, commenters
|
||||
access := map[string]bool{t.ConsultantID: true}
|
||||
if c, err := s.store.CustomerByID(ctx, t.CustomerID); err == nil {
|
||||
for _, id := range c.ConsultantIDs {
|
||||
access[id] = true
|
||||
}
|
||||
}
|
||||
if t.Assignee != nil && t.Assignee.UserID != "" {
|
||||
ids[t.Assignee.UserID] = true
|
||||
access[t.Assignee.UserID] = true
|
||||
}
|
||||
for _, cr := range t.ClaimRequests {
|
||||
ids[cr.DeveloperID] = true
|
||||
access[cr.DeveloperID] = true
|
||||
}
|
||||
for _, c := range t.Comments {
|
||||
ids[c.AuthorID] = true
|
||||
access[c.AuthorID] = true
|
||||
}
|
||||
notified := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
token := strings.ToLower(m[1])
|
||||
for id := range ids {
|
||||
if id == author.ID || notified[id] {
|
||||
continue
|
||||
}
|
||||
u, err := s.store.UserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
||||
if token == strings.ToLower(u.Email) || token == first {
|
||||
notified[id] = true
|
||||
s.notifyUser(r.Context(), id, "mention",
|
||||
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
|
||||
}
|
||||
uid := m[1]
|
||||
if uid == author.ID || notified[uid] || !access[uid] {
|
||||
continue
|
||||
}
|
||||
notified[uid] = true
|
||||
s.notifyUser(ctx, uid, "mention",
|
||||
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,19 +232,21 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
|
||||
}
|
||||
// Notify mentioned users who are participants (i.e. have access §11.1).
|
||||
plain := chat.SanitizePlain(body)
|
||||
participants := map[string]bool{}
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid == me.ID {
|
||||
participants[pid] = true
|
||||
}
|
||||
notified := map[string]bool{}
|
||||
for _, m := range mentionUIDRe.FindAllStringSubmatch(body, 20) {
|
||||
uid := m[1]
|
||||
if uid == me.ID || notified[uid] || !participants[uid] {
|
||||
continue
|
||||
}
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
||||
lower := strings.ToLower(plain)
|
||||
if strings.Contains(lower, "@"+strings.ToLower(u.Email)) || strings.Contains(lower, "@"+first) {
|
||||
s.notifyUser(r.Context(), pid, "mention",
|
||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||
}
|
||||
}
|
||||
notified[uid] = true
|
||||
s.notifyUser(r.Context(), uid, "mention",
|
||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
||||
}
|
||||
|
||||
@@ -766,6 +766,27 @@ input[type=range] { width: 100%; accent-color: var(--accent); }
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, black);
|
||||
}
|
||||
|
||||
/* ---- @-mention chips + links inside chat/comments ---- */
|
||||
.mention {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mention:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* links in messages and comments are underlined so they read as clickable */
|
||||
.chat-msg a:not(.btn), .cw-msg a:not(.btn), .comment-body a:not(.btn) {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- @-mention autocomplete menu ---- */
|
||||
.mention-menu {
|
||||
position: absolute; z-index: 80;
|
||||
|
||||
@@ -11,6 +11,12 @@ let tasks = [];
|
||||
let atomizerUp = true;
|
||||
const selected = new Set();
|
||||
|
||||
// Hold Ctrl/Cmd while dragging an effort slider to rebalance siblings.
|
||||
let ctrlHeld = false;
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = true; });
|
||||
window.addEventListener('keyup', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = false; });
|
||||
window.addEventListener('blur', () => { ctrlHeld = false; });
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
@@ -115,14 +121,20 @@ function renderChildren(parent, kids, depth) {
|
||||
wrap.className = 'stack mt';
|
||||
|
||||
const subdivided = kids.filter((k) => k.origin === 'subdivided');
|
||||
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
|
||||
const hasExtension = kids.some((k) => k.origin === 'extended');
|
||||
if (subdivided.length) {
|
||||
const ind = document.createElement('p');
|
||||
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
|
||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
|
||||
const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows
|
||||
let ind = null;
|
||||
function updateSumIndicator() {
|
||||
if (!ind) return;
|
||||
const s = subSiblings.reduce((a, x) => a + parseFloat(x.slider.value), 0);
|
||||
const bad = !hasExtension && Math.abs(s - 1) > 0.005;
|
||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${s.toFixed(2)}</strong>` +
|
||||
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
|
||||
: bad ? ' — should be 1.00' : '');
|
||||
: bad ? ' — should be 1.00' : '') +
|
||||
' <span class="muted" style="font-size:0.82rem">· hold Ctrl while dragging to rebalance the others</span>';
|
||||
}
|
||||
if (subdivided.length) {
|
||||
ind = document.createElement('p');
|
||||
wrap.appendChild(ind);
|
||||
}
|
||||
|
||||
@@ -157,25 +169,54 @@ function renderChildren(parent, kids, depth) {
|
||||
</span>
|
||||
</div>`;
|
||||
const slider = row.querySelector('input[type=range]');
|
||||
const coeffEl = row.querySelector('[data-coeff]');
|
||||
const bountyEl = row.querySelector('[data-bounty]');
|
||||
if (k.origin === 'subdivided' && !slider.disabled) {
|
||||
subSiblings.push({ k, slider, coeffEl, bountyEl });
|
||||
}
|
||||
let sliderTimer = null;
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
slider.addEventListener('input', () => {
|
||||
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
|
||||
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
|
||||
const newVal = parseFloat(slider.value);
|
||||
coeffEl.textContent = newVal.toFixed(2);
|
||||
bountyEl.textContent = (newVal * k.budget).toFixed(2);
|
||||
const changed = [{ k, value: newVal }];
|
||||
// Ctrl/Cmd held: rebalance the other subdivided siblings so the sum
|
||||
// stays ~1.00 (increase one → the rest shrink proportionally).
|
||||
if (ctrlHeld && k.origin === 'subdivided') {
|
||||
const others = subSiblings.filter((s) => s.k.id !== k.id);
|
||||
const curOthers = others.reduce((a, s) => a + parseFloat(s.slider.value), 0);
|
||||
const targetOthers = Math.max(0, 1 - newVal);
|
||||
if (others.length && curOthers > 0) {
|
||||
const scale = targetOthers / curOthers;
|
||||
others.forEach((s) => {
|
||||
const max = parseFloat(s.slider.max);
|
||||
const nv = round2(Math.max(0.01, Math.min(max, parseFloat(s.slider.value) * scale)));
|
||||
s.slider.value = nv;
|
||||
s.coeffEl.textContent = nv.toFixed(2);
|
||||
s.bountyEl.textContent = (nv * s.k.budget).toFixed(2);
|
||||
changed.push({ k: s.k, value: nv });
|
||||
});
|
||||
}
|
||||
updateSumIndicator();
|
||||
}
|
||||
clearTimeout(sliderTimer);
|
||||
sliderTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
|
||||
effortCoefficient: parseFloat(slider.value), version: k.version,
|
||||
});
|
||||
k.version = res.task.version;
|
||||
k.effortCoefficient = res.task.effortCoefficient;
|
||||
k.bounty = res.task.bounty;
|
||||
for (const ch of changed) {
|
||||
const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, {
|
||||
effortCoefficient: ch.value, version: ch.k.version,
|
||||
});
|
||||
ch.k.version = res.task.version;
|
||||
ch.k.effortCoefficient = res.task.effortCoefficient;
|
||||
ch.k.bounty = res.task.bounty;
|
||||
}
|
||||
render();
|
||||
} catch (e) {
|
||||
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
|
||||
else toast(e.message, 'err');
|
||||
}
|
||||
}, 400);
|
||||
}, 450);
|
||||
});
|
||||
const sel = row.querySelector('[data-sel]');
|
||||
if (sel) {
|
||||
@@ -200,6 +241,7 @@ function renderChildren(parent, kids, depth) {
|
||||
const grand = childrenOf(k.id);
|
||||
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
|
||||
});
|
||||
updateSumIndicator();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// plain-text composer. Reuses the conversations API + live WS channel.
|
||||
import { api } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
import { attachMentions } from '/static/js/mention.js';
|
||||
import { attachMentions, mentionHTML } from '/static/js/mention.js';
|
||||
|
||||
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
|
||||
let meId = '';
|
||||
@@ -113,7 +113,7 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages')
|
||||
input.value = '';
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
||||
body: '<p>' + esc(text) + '</p>',
|
||||
body: '<p>' + mentionHTML(text, input) + '</p>',
|
||||
});
|
||||
} catch (e) { input.value = text; }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export function attachMentions(el) {
|
||||
let items = [];
|
||||
let active = 0;
|
||||
let seq = 0;
|
||||
el._mentions = el._mentions || new Map(); // display name -> user id
|
||||
|
||||
function close() {
|
||||
menu.hidden = true;
|
||||
@@ -89,18 +90,33 @@ export function attachMentions(el) {
|
||||
}
|
||||
|
||||
function pick(u, ctx) {
|
||||
const text = '@' + u.name + ' ';
|
||||
el._mentions.set(u.name, u.id);
|
||||
if (isCE) {
|
||||
// insert an atomic mention chip + a trailing space in the text node
|
||||
const node = ctx.node;
|
||||
const t = node.textContent;
|
||||
node.textContent = t.slice(0, ctx.start) + text + t.slice(ctx.end);
|
||||
const full = node.textContent;
|
||||
const after = full.slice(ctx.end);
|
||||
node.textContent = full.slice(0, ctx.start);
|
||||
const span = document.createElement('span');
|
||||
span.className = 'mention';
|
||||
span.dataset.userCard = u.id;
|
||||
span.contentEditable = 'false';
|
||||
span.textContent = '@' + u.name;
|
||||
// the chip is an atomic, non-editable element, so the trailing space in
|
||||
// this separate text node stays put as the user keeps typing
|
||||
const tail = document.createTextNode(' ' + after);
|
||||
const parent = node.parentNode;
|
||||
const ref = node.nextSibling;
|
||||
parent.insertBefore(span, ref);
|
||||
parent.insertBefore(tail, ref);
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(node, ctx.start + text.length);
|
||||
range.setStart(tail, 1);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
const text = '@' + u.name + ' ';
|
||||
const v = el.value;
|
||||
el.value = v.slice(0, ctx.start) + text + v.slice(ctx.end);
|
||||
const caret = ctx.start + text.length;
|
||||
@@ -122,6 +138,29 @@ export function attachMentions(el) {
|
||||
el.addEventListener('blur', () => setTimeout(close, 150));
|
||||
}
|
||||
|
||||
// mentionHTML turns the plain text of a textarea/input into safe HTML, wrapping
|
||||
// any recorded "@Name" run in a mention span carrying the user id. Names with
|
||||
// spaces are matched exactly (longest first) against what the picker inserted.
|
||||
export function mentionHTML(text, el) {
|
||||
const mentions = (el && el._mentions) || new Map();
|
||||
const names = [...mentions.keys()].sort((a, b) => b.length - a.length);
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text[i] === '@') {
|
||||
const name = names.find((n) => text.startsWith('@' + n, i));
|
||||
if (name) {
|
||||
out += `<span class="mention" data-user-card="${escapeHtml(mentions.get(name))}">@${escapeHtml(name)}</span>`;
|
||||
i += 1 + name.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += escapeHtml(text[i]);
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
|
||||
@@ -81,3 +81,11 @@ document.addEventListener('mouseout', (e) => {
|
||||
hideTimer = setTimeout(hideCard, 250);
|
||||
}
|
||||
});
|
||||
// click a mention (or any user-card target) to open its card immediately
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('[data-user-card]');
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
clearTimeout(hideTimer);
|
||||
showCard(target, target.dataset.userCard);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe } from '/static/js/ws.js';
|
||||
import { attachMentions } from '/static/js/mention.js';
|
||||
import { attachMentions, mentionHTML } from '/static/js/mention.js';
|
||||
|
||||
const root = document.getElementById('task-root');
|
||||
const errorBox = document.getElementById('error');
|
||||
@@ -58,7 +58,7 @@ async function render() {
|
||||
const comments = await Promise.all((t.comments || []).map(async (c) => `
|
||||
<div class="card" style="padding:12px">
|
||||
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
|
||||
<div>${c.body}</div>
|
||||
<div class="comment-body">${c.body}</div>
|
||||
</div>`));
|
||||
|
||||
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
|
||||
@@ -183,9 +183,10 @@ function wireHandlers() {
|
||||
});
|
||||
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = document.getElementById('comment-body').value;
|
||||
const field = document.getElementById('comment-body');
|
||||
const body = mentionHTML(field.value, field).replace(/\n/g, '<br>');
|
||||
try {
|
||||
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
|
||||
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + body + '</p>' });
|
||||
load();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user