diff --git a/internal/chat/sanitize.go b/internal/chat/sanitize.go index a4c609a..7826e74 100644 --- a/internal/chat/sanitize.go +++ b/internal/chat/sanitize.go @@ -53,6 +53,31 @@ func SanitizeHTML(in string) string { name, attrs := splitTag(raw) name = strings.ToLower(name) + // @-mention tokens: @Name. + // 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("") + } + break + } + } + continue + } + uid := mentionUID(attrs) + if uid == "" { + continue // drop the opening span, keep its text + } + out.WriteString(``) + 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) diff --git a/internal/http/board.go b/internal/http/board.go index 7294102..5cd8390 100644 --- a/internal/http/board.go +++ b/internal/http/board.go @@ -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: +// @Name. +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) } } diff --git a/internal/http/messages.go b/internal/http/messages.go index dc2aa75..80f1193 100644 --- a/internal/http/messages.go +++ b/internal/http/messages.go @@ -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}) } diff --git a/web/static/css/app.css b/web/static/css/app.css index 4d93eea..d33e7a9 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -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; diff --git a/web/static/js/atomization.js b/web/static/js/atomization.js index dc3f4f6..9d5aaf2 100644 --- a/web/static/js/atomization.js +++ b/web/static/js/atomization.js @@ -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: ${sum.toFixed(2)}` + + 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: ${s.toFixed(2)}` + (hasExtension ? ' (extensions allow > 1.00)' - : bad ? ' — should be 1.00' : ''); + : bad ? ' — should be 1.00' : '') + + ' · hold Ctrl while dragging to rebalance the others'; + } + if (subdivided.length) { + ind = document.createElement('p'); wrap.appendChild(ind); } @@ -157,25 +169,54 @@ function renderChildren(parent, kids, depth) { `; 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; } diff --git a/web/static/js/chat-widget.js b/web/static/js/chat-widget.js index d777a81..953185c 100644 --- a/web/static/js/chat-widget.js +++ b/web/static/js/chat-widget.js @@ -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: '

' + esc(text) + '

', + body: '

' + mentionHTML(text, input) + '

', }); } catch (e) { input.value = text; } } diff --git a/web/static/js/mention.js b/web/static/js/mention.js index b64b643..27bea66 100644 --- a/web/static/js/mention.js +++ b/web/static/js/mention.js @@ -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 += `@${escapeHtml(name)}`; + i += 1 + name.length; + continue; + } + } + out += escapeHtml(text[i]); + i += 1; + } + return out; +} + function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', diff --git a/web/static/js/shortcuts.js b/web/static/js/shortcuts.js index a61e3a0..2864b41 100644 --- a/web/static/js/shortcuts.js +++ b/web/static/js/shortcuts.js @@ -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); +}); diff --git a/web/static/js/task.js b/web/static/js/task.js index 9576611..ab07488 100644 --- a/web/static/js/task.js +++ b/web/static/js/task.js @@ -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) => `

${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}

-
${c.body}
+
${c.body}
`)); 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, '
'); try { - await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '

' + esc(body).replace(/\n/g, '
') + '

' }); + await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '

' + body + '

' }); load(); } catch (err) { toast(err.message, 'err'); } });