540871a8cdc2c7df89bd3c0f5eb39387359db062 / frogue / src / mentions.rs
Raw
// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! ActivityPub mention and hashtag extraction, resolution, and rendering.
//!
//! Mentions are written as `@username` for local users and `@user@host` for
//! remote actors. Hashtags are written as `#tag`.
//!
//! Extraction works on the markdown source and skips code spans, code blocks,
//! and existing links. Resolution only uses the local database: local users
//! and remote actors that are already cached. Unknown remote actors are left
//! unresolved rather than triggering network requests from a document builder.
use std::collections::BTreeSet;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use crate::activitypub::types::Tag;
/// A mention as it was written in the source.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct MentionRequest {
/// Username portion, without the leading `@`.
pub username: String,
/// Remote host, or `None` for a local mention.
pub host: Option<String>,
}
impl MentionRequest {
/// The normalized handle, e.g. `@alice` or `@alice@example.org`.
pub fn handle(&self) -> String {
match &self.host {
Some(host) => format!("@{}@{}", self.username, host),
None => format!("@{}", self.username),
}
}
}
/// A mention that could be resolved to an actor URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMention {
pub handle: String,
pub href: String,
}
/// Extracts mentions and hashtags from a markdown source.
///
/// Returns unique mentions in source order and unique hashtags in source order.
pub fn parse(source: &str) -> (Vec<MentionRequest>, Vec<String>) {
use pulldown_cmark::{Event, Parser, Tag as MdTag, TagEnd};
let mut mentions = Vec::new();
let mut seen_mentions = BTreeSet::new();
let mut hashtags = Vec::new();
let mut seen_hashtags = BTreeSet::new();
let mut code_depth = 0usize;
let mut link_depth = 0usize;
for event in Parser::new_ext(source, crate::markdown::options()) {
match event {
Event::Start(MdTag::CodeBlock(_)) => code_depth += 1,
Event::End(TagEnd::CodeBlock) => code_depth = code_depth.saturating_sub(1),
Event::Code(_) => {}
Event::Start(MdTag::Link { .. }) => link_depth += 1,
Event::End(TagEnd::Link) => link_depth = link_depth.saturating_sub(1),
Event::Text(text) if code_depth == 0 && link_depth == 0 => {
scan_text(
text.as_ref(),
&mut mentions,
&mut seen_mentions,
&mut hashtags,
&mut seen_hashtags,
);
}
_ => {}
}
}
(mentions, hashtags)
}
fn scan_text(
text: &str,
mentions: &mut Vec<MentionRequest>,
seen_mentions: &mut BTreeSet<MentionRequest>,
hashtags: &mut Vec<String>,
seen_hashtags: &mut BTreeSet<String>,
) {
let bytes = text.as_bytes();
let mut pos = 0;
while pos < bytes.len() {
match bytes[pos] {
b'@' => {
let before_ok = pos == 0 || !is_word_byte(bytes[pos - 1]);
if !before_ok {
pos += 1;
continue;
}
let username_start = pos + 1;
let username_end = scan_while(text, username_start, is_username_byte);
if username_end == username_start {
pos += 1;
continue;
}
let username = &text[username_start..username_end];
let (host, end) = if bytes.get(username_end) == Some(&b'@') {
let host_start = username_end + 1;
let host_end = scan_while(text, host_start, is_host_byte);
if host_end == host_start {
(None, username_end)
} else {
(Some(text[host_start..host_end].to_string()), host_end)
}
} else {
(None, username_end)
};
let request = MentionRequest {
username: username.to_string(),
host,
};
if seen_mentions.insert(request.clone()) {
mentions.push(request);
}
pos = end;
}
b'#' => {
let before_ok = pos == 0 || !is_word_byte(bytes[pos - 1]);
if !before_ok {
pos += 1;
continue;
}
let tag_start = pos + 1;
let tag_end = scan_while(text, tag_start, is_hashtag_byte);
// `#123` is a ticket shorthand, not a hashtag.
if tag_end == tag_start
|| text[tag_start..tag_end].bytes().all(|b| b.is_ascii_digit())
{
pos += 1;
continue;
}
let tag = text[tag_start..tag_end].to_string();
if seen_hashtags.insert(tag.clone()) {
hashtags.push(tag);
}
pos = tag_end;
}
_ => pos += 1,
}
}
}
fn scan_while(text: &str, start: usize, predicate: fn(u8) -> bool) -> usize {
let bytes = text.as_bytes();
let mut end = start;
while end < bytes.len() && predicate(bytes[end]) {
end += 1;
}
end
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn is_username_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
}
fn is_host_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')
}
fn is_hashtag_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
/// Resolves mentions against local users and cached remote actors.
///
/// Unresolvable mentions are omitted.
pub async fn resolve(
db: &DatabaseConnection,
public_url: &str,
requests: &[MentionRequest],
) -> Vec<ResolvedMention> {
use crate::entities::prelude::{ApRemoteActors, Users};
use crate::entities::{ap_remote_actors, users};
let mut resolved = Vec::new();
for request in requests {
let Some(host) = &request.host else {
let user = Users::find()
.filter(users::Column::Name.eq(request.username.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(db)
.await
.ok()
.flatten();
if let Some(user) = user {
resolved.push(ResolvedMention {
handle: request.handle(),
href: crate::activitypub::ApUrls::new(public_url).actor(&user.name),
});
}
continue;
};
let actor = ApRemoteActors::find()
.filter(ap_remote_actors::Column::PreferredUsername.eq(request.username.as_str()))
.all(db)
.await
.ok()
.unwrap_or_default()
.into_iter()
.find(|actor| actor_host(&actor.actor_url).as_deref() == Some(host.as_str()));
if let Some(actor) = actor {
resolved.push(ResolvedMention {
handle: request.handle(),
href: actor.actor_url,
});
}
}
resolved
}
fn actor_host(actor_url: &str) -> Option<String> {
url::Url::parse(actor_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
}
/// Builds the ActivityStreams `tag` list for resolved mentions and hashtags.
pub fn tags(mentions: &[ResolvedMention], hashtags: &[String], public_url: &str) -> Vec<Tag> {
let mut tags = Vec::new();
for mention in mentions {
tags.push(Tag {
kind: "Mention".to_string(),
href: mention.href.clone(),
name: Some(mention.handle.clone()),
});
}
for hashtag in hashtags {
tags.push(Tag {
kind: "Hashtag".to_string(),
href: format!("{}/tags/{}", public_url.trim_end_matches('/'), hashtag),
name: Some(format!("#{hashtag}")),
});
}
tags
}
/// Adds resolved mention actor URLs to an addressing list, avoiding duplicates.
pub fn add_mention_targets(cc: &mut Vec<String>, mentions: &[ResolvedMention]) {
for mention in mentions {
if !cc.contains(&mention.href) {
cc.push(mention.href.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_local_and_remote_mentions() {
let (mentions, hashtags) = parse("Hi @alice and @bob@example.org, see #ActivityPub!");
assert_eq!(
mentions,
vec![
MentionRequest {
username: "alice".into(),
host: None,
},
MentionRequest {
username: "bob".into(),
host: Some("example.org".into()),
},
]
);
assert_eq!(hashtags, vec!["ActivityPub"]);
}
#[test]
fn ignores_mentions_in_code_and_links() {
let (mentions, hashtags) =
parse("`@alice` and [@bob](https://example.org) and\n\n```\n@carol #rust\n```\n");
assert!(mentions.is_empty(), "{mentions:?}");
assert!(hashtags.is_empty(), "{hashtags:?}");
}
#[test]
fn does_not_treat_ticket_refs_as_hashtags() {
let (_, hashtags) = parse("Fixes #12 and #ActivityPub #123abc");
assert_eq!(hashtags, vec!["ActivityPub", "123abc"]);
}
#[test]
fn deduplicates_mentions_and_hashtags() {
let (mentions, hashtags) = parse("@alice @alice #rust #rust");
assert_eq!(mentions.len(), 1);
assert_eq!(hashtags, vec!["rust"]);
}
#[test]
fn ignores_email_local_part() {
let (mentions, _) = parse("mail me at bob@example.org");
assert!(mentions.is_empty(), "{mentions:?}");
}
}