Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -2278,3 +2278,69 @@ body:has(.comment-toolbar) .main-content {
display: none !important;
}
}

/* @-mention chip — matches Google Docs style. Uses theme tokens so it
adapts to dark mode automatically via CoPlan's existing variables. */
.markdown-rendered .mention {
display: inline;
background: var(--color-primary-light);
color: var(--color-primary);
border-radius: 12px;
padding: 1px 8px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
}

/* Mention picker dropdown for the comment textarea */
.mention-picker {
position: absolute;
z-index: 100;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 240px;
max-width: 360px;
max-height: 240px;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 4px 0;
}
.mention-picker[hidden] { display: none; }
.mention-picker__item {
padding: 6px 12px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.mention-picker__item--highlighted {
background: var(--color-primary-light);
color: var(--color-primary);
}
.mention-picker__item--highlighted .mention-picker__name {
color: var(--color-primary);
}
.mention-picker__item--disabled {
cursor: not-allowed;
opacity: 0.55;
}
.mention-picker__item--disabled.mention-picker__item--highlighted {
background: transparent;
}
.mention-picker__name {
font-weight: 500;
color: var(--color-text);
}
.mention-picker__meta {
font-size: 0.85em;
color: var(--color-text-muted);
}
.mention-picker__empty {
padding: 6px 12px;
color: var(--color-text-muted);
font-size: 0.9em;
}
36 changes: 0 additions & 36 deletions engine/app/controllers/coplan/api/v1/users_controller.rb

This file was deleted.

42 changes: 42 additions & 0 deletions engine/app/controllers/coplan/users_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module CoPlan
# Session-authenticated user typeahead for in-app pickers (the @-mention
# picker in the comment textarea today; future pickers welcome).
class UsersController < ApplicationController
ALLOWED_FIELDS = %i[id name email username avatar_url title team].freeze

def search
query = params[:q].to_s.strip
if query.blank?
return render json: []
end

users = if CoPlan.configuration.user_search
# Hook may return external candidates (LDAP, etc.) — keep only those
# whose username also exists in coplan_users, since RewriteMentions
# and ProcessMentions can only resolve local usernames. Surfacing
# external-only users would let the picker offer mentions that
# silently fall through to plain text on save.
candidates = CoPlan.configuration.user_search.call(query)
local_usernames = CoPlan::User.where(username: candidates.filter_map { |c| c[:username] || c["username"] }).pluck(:username).to_set
candidates.select { |c| local_usernames.include?(c[:username] || c["username"]) }
else
sanitized = CoPlan::User.sanitize_sql_like(query)
CoPlan::User
.where("name LIKE :q OR email LIKE :q OR username LIKE :q", q: "%#{sanitized}%")
.limit(20)
end

render json: users.map { |u| user_json(u) }
end

private

def user_json(user)
if user.respond_to?(:id)
ALLOWED_FIELDS.to_h { |f| [f, user.public_send(f)] }
else
ALLOWED_FIELDS.to_h { |f| [f, user[f]] }
end
end
end
end
32 changes: 30 additions & 2 deletions engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,43 @@ module MarkdownHelper
details summary
].freeze

ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target].freeze
ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target data-mention-username].freeze

# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
# dashes, and underscores. The pattern must round-trip exactly so that
# casual `[foo](mention:bar)` typed by hand doesn't get rendered as a chip.
MENTION_PATTERN = /\[@([\w.-]+)\]\(mention:\1\)/

def render_markdown(content, interactive: true)
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil })
sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
with_chips = transform_mention_anchors(html)
sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized
tag.div(result.html_safe, class: "markdown-rendered")
end

# Replaces `<a href="mention:username">@username</a>` produced by
# Commonmarker with a styled `<span>` chip. Runs on the parsed HTML so
# that mentions inside fenced code blocks or inline code stay as literal
# text — Commonmarker doesn't emit `<a>` tags inside code, so they're
# naturally skipped here.
def transform_mention_anchors(html)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
doc.css('a[href^="mention:"]').each do |anchor|
username = anchor["href"].sub(/\Amention:/, "")
next unless username.match?(/\A[\w.-]+\z/)
next unless anchor.content == "@#{username}"

span = Nokogiri::XML::Node.new("span", doc)
span["class"] = "mention"
span["data-mention-username"] = username
span.content = "@#{username}"
anchor.replace(span)
end
doc.to_html
end

def markdown_to_plain_text(content)
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), plugins: { syntax_highlighter: nil })
Nokogiri::HTML::DocumentFragment.parse(html).text.squish
Expand Down
Loading
Loading