PhoenixKit.Mentions (phoenix_kit v2.0.1)

Copy Markdown View Source

Cross-module @ pings and # record links.

Typing @ anywhere a person can write free text offers people; typing # offers records from every installed module. The picked result is stored as a self-contained token (PhoenixKit.Mentions.Token), indexed for reverse lookup (PhoenixKit.Mentions.Mention), and rendered per viewer.

The three halves

  • Searchsearch/2 fans out across every module that declares a searchable resource type, in parallel, with a hard deadline. A module that is slow, raises, or has no index costs its own results and nothing else: the popup shows what came back.

  • Resolve — reuses PhoenixKit.ResourceLinks, which already turns (type, uuid) into a title and a deep-link for the Activity feed and Comments. Batched per type, so a page of mentions costs one call per distinct type, not one per mention.

  • Visibilityvisible/3 asks each type's handler which of these uuids THIS viewer may see. Separate from resolution on purpose: the searcher and the later reader are different people, and a record that was visible when it was linked may not be when it is read.

What a viewer sees

SituationRendered as
Resolves, viewer may see itlive title, real deep-link
Gone, or its module uninstalledthe author's stored label, plain text
Exists, viewer may NOT see ita redacted chip — never the title

The middle row is why the label lives in the text. The bottom row is the rule that matters: a mention must never show a viewer a title they are not allowed to know, and it must never refresh one. What it does show is that something is there — the Discord model — with a way to ask for access, because "you can't see this" is only useful with a next step.

Making a module's records mentionable

Add the type to resource_links/0 pointing at a handler module, then give that handler two functions beyond the resolve_comment_resources/1 it already needs for Activity and Comments:

defmodule MyApp.Widgets.Links do
  # Already required for deep-linking in Activity/Comments
  def resolve_comment_resources(uuids), do: %{...}

  # New: the `#` typeahead. MUST scope to the searcher.
  def search_resources(query, opts) do
    user_uuid = opts[:user_uuid]
    ...
    [%{type: "widget", uuid: w.uuid, title: w.name, subtitle: "Widget"}]
  end

  # New: which of these may this viewer see? Batched.
  def visible_resource_uuids(uuids, opts), do: [...]
end

A handler without search_resources/2 is resolvable but not offerable — reasonable for a type that should be linkable from an activity row yet never suggested by the typeahead.

visible_resource_uuids/2 is what makes a type mentionable at all. Without it, a mention of that type renders REDACTED, because a token can be typed by hand into any textarea and "the module didn't implement the check" must never mean "show it to everyone". The only exception is user: the @ typeahead already lists every pingable account to anyone who can reach the admin area, so a name is not something a mention can leak.

Summary

Functions

CLAIMS delivery for these mentions, returning the ones this caller won.

Everything a renderer needs for the mentions in text, in one pass.

Master switch. Defaults ON — the feature is additive and inert until someone actually types a trigger.

What links here: every record mentioning this one, newest first.

Every mention recorded for one field of one record.

Delivers the @ pings among mentions and marks them sent.

Extra-security mode: withhold the TITLE of a mention the viewer cannot open, showing the label the author stored instead of a live one.

Candidates for the typeahead.

Handler modules that can be searched, as %{resource_type => module}.

Rebuilds the index for one field of one record from its text, and returns the mentions that are NEW since last time.

Rewrites the mentions in text as MARKDOWN, resolved for this viewer.

Of uuids for type, the ones this viewer may see.

Functions

claim_for_delivery(mentions)

@spec claim_for_delivery([PhoenixKit.Mentions.Mention.t()]) :: [
  PhoenixKit.Mentions.Mention.t()
]

CLAIMS delivery for these mentions, returning the ones this caller won.

WHERE notified_at IS NULL is what makes it a claim rather than a stamp: two saves of the same field racing (a double submit, two tabs) both see an unnotified row and would both send. Only the update that actually changes a row may deliver.

context(text, opts \\ [])

@spec context(
  String.t() | nil,
  keyword()
) :: map()

Everything a renderer needs for the mentions in text, in one pass.

Returns %{{type, uuid} => %{state, title, path, prefixed}} where state is :ok, :missing, or :forbidden. Batched per type: a page with thirty mentions across three types costs three resolve calls and three visibility calls.

enabled?()

@spec enabled?() :: boolean()

Master switch. Defaults ON — the feature is additive and inert until someone actually types a trigger.

enabled_setting_key()

@spec enabled_setting_key() :: String.t()

list_backlinks(target_type, target_uuid, opts \\ [])

@spec list_backlinks(String.t(), String.t(), keyword()) :: [
  PhoenixKit.Mentions.Mention.t()
]

What links here: every record mentioning this one, newest first.

The backlink panel on a record's own page. Not permission-filtered — the caller knows who is asking and what their own module's rules are.

list_for_source(source_type, source_uuid, field \\ "body")

@spec list_for_source(String.t(), String.t(), String.t()) :: [
  PhoenixKit.Mentions.Mention.t()
]

Every mention recorded for one field of one record.

notify(mentions, opts \\ [])

@spec notify(
  list(),
  keyword()
) :: :ok

Delivers the @ pings among mentions and marks them sent.

Call it with what sync/4 returned — those are the ones that are new. Everything else is filtered here rather than by the caller:

  • # links never notify. Telling a record's watchers it was referenced is a different product (and a noisy one); the reverse index is enough to build a "mentioned in" panel when that is actually wanted.
  • mentioning yourself does nothing.
  • someone who cannot open the containing record is not told about it. An email about a comment that 403s on click is worse than silence.

Delivery itself is core's activity → notification bridge, so a ping lands wherever that person already reads things and obeys their existing preferences and channels.

redact_setting_key()

@spec redact_setting_key() :: String.t()

redact_titles?()

@spec redact_titles?() :: boolean()

Extra-security mode: withhold the TITLE of a mention the viewer cannot open, showing the label the author stored instead of a live one.

Off by default. On, a record renamed after being mentioned never shows its new name to someone who can't open it — worth having where the name itself is the sensitive part, and unnecessary noise where it isn't.

search(kind, query, opts \\ [])

@spec search(:user | :resource, String.t(), keyword()) :: [map()]

Candidates for the typeahead.

kind is :user for @ (core's users only — a ping is always a person) or :resource for # (every module that opted in).

Options:

  • :user_uuid — who is searching. Handlers MUST scope to this; a typeahead that offers records the searcher cannot open is itself the leak.
  • :limit — max results overall (default 8).

Never raises: a handler that blows up contributes nothing and is logged.

searchable_handlers()

@spec searchable_handlers() :: %{required(String.t()) => module()}

Handler modules that can be searched, as %{resource_type => module}.

Derived from the same resource_links/0 registry that powers deep-linking — a type is offerable only if it is already resolvable, so a mention can never be created that cannot later be rendered.

sync(source_type, source_uuid, text, opts \\ [])

@spec sync(String.t(), String.t(), String.t() | nil, keyword()) ::
  {:ok, [PhoenixKit.Mentions.Mention.t()]} | {:error, term()}

Rebuilds the index for one field of one record from its text, and returns the mentions that are NEW since last time.

Call this from the durable save — creating or updating the comment, the task, the note. Never from a keystroke or an autosave draft: the return value is what gets notified, and a debounce would ping on every pause.

The delete-then-insert is scoped to this source_field, so a record with two mentionable fields keeps them independent.

to_markdown(text, opts \\ [])

@spec to_markdown(
  String.t() | nil,
  keyword()
) :: String.t()

Rewrites the mentions in text as MARKDOWN, resolved for this viewer.

For surfaces that render markdown rather than HEEx — comments, notes, anything going through MDEx. Same three states as the component, with one honest limitation: markdown output is sanitised afterwards, so a redacted mention here is plain text rather than a button. The reader still learns that something exists and that it isn't theirs; they just can't ask for access from inside a rendered comment.

Mentions.to_markdown(comment.content, scope: scope)

Text with no mentions is returned unchanged, so this is safe to put in front of every markdown render.

visible(type, uuids, opts)

@spec visible(String.t(), [String.t()], keyword()) :: [String.t()]

Of uuids for type, the ones this viewer may see.

A handler with no visible_resource_uuids/2 is treated as public — see the moduledoc. Anything that raises fails CLOSED (nothing visible): a broken permission check must not become an open door.