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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user