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:
etalon
2026-06-13 12:09:17 +02:00
parent 70cb664246
commit b232b4b3d3
9 changed files with 239 additions and 59 deletions
+69
View File
@@ -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)