[READ-ONLY] Mirror of https://github.com/colibri-social/appview. The AppView (backend server) for Colibri api.colibri.social
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: Mentionable roles

Louis Escher (Jul 10, 2026, 12:51 AM +0200) f4a9ffd1 e0163b88

+266 -10
+2
migration/src/lib.rs
··· 11 11 mod m20260625_000000_create_push_subscriptions; 12 12 mod m20260628_120000_record_data_data_to_jsonb; 13 13 mod m20260709_000000_add_vc_state_to_user_states; 14 + mod m20260710_000000_add_mention_role_to_notifications; 14 15 15 16 pub struct Migrator; 16 17 ··· 29 30 Box::new(m20260625_000000_create_push_subscriptions::Migration), 30 31 Box::new(m20260628_120000_record_data_data_to_jsonb::Migration), 31 32 Box::new(m20260709_000000_add_vc_state_to_user_states::Migration), 33 + Box::new(m20260710_000000_add_mention_role_to_notifications::Migration), 32 34 ] 33 35 } 34 36 }
+31
migration/src/m20260710_000000_add_mention_role_to_notifications.rs
··· 1 + use sea_orm_migration::prelude::*; 2 + 3 + #[derive(DeriveMigrationName)] 4 + pub struct Migration; 5 + 6 + #[async_trait::async_trait] 7 + impl MigrationTrait for Migration { 8 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 + manager 10 + .alter_table( 11 + Table::alter() 12 + .table(Alias::new("notifications")) 13 + .add_column_if_not_exists( 14 + ColumnDef::new(Alias::new("mention_role_name")).text().null(), 15 + ) 16 + .to_owned(), 17 + ) 18 + .await 19 + } 20 + 21 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 22 + manager 23 + .alter_table( 24 + Table::alter() 25 + .table(Alias::new("notifications")) 26 + .drop_column(Alias::new("mention_role_name")) 27 + .to_owned(), 28 + ) 29 + .await 30 + } 31 + }
+6
src/lib/events.rs
··· 312 312 pub channel_uri: String, 313 313 #[serde(rename = "indexedAt")] 314 314 pub indexed_at: String, 315 + #[serde( 316 + rename = "mentionRoleName", 317 + default, 318 + skip_serializing_if = "Option::is_none" 319 + )] 320 + pub mention_role_name: Option<String>, 315 321 pub message: NotificationEventMessage, 316 322 } 317 323
+210 -7
src/lib/notifications.rs
··· 8 8 use serde_json::Value; 9 9 10 10 use crate::lib::at_uri::AtUri; 11 - use crate::lib::colibri::ColibriMessage; 11 + use crate::lib::colibri::{ColibriMember, ColibriMessage, ColibriRole}; 12 + use crate::lib::community_authz::load_actor_authz; 13 + use crate::lib::permissions::Permission; 12 14 use crate::lib::time::current_iso8601_utc; 13 15 use crate::models::{notifications, record_data}; 14 16 15 17 pub const KIND_MENTION: &str = "mention"; 16 18 pub const KIND_REPLY: &str = "reply"; 17 19 pub const FACET_MENTION_TYPE: &str = "social.colibri.richtext.facet#mention"; 20 + pub const FACET_ROLE_TYPE: &str = "social.colibri.richtext.facet#role"; 21 + const MEMBER_NSID: &str = "social.colibri.member"; 22 + const ROLE_NSID: &str = "social.colibri.role"; 18 23 19 24 /// Bundle carried over the WS-broadcast channel so each subscriber can render 20 25 /// a notification without having to fetch the message body separately. ··· 72 77 pub indexed_at: String, 73 78 #[serde(rename = "seenAt", skip_serializing_if = "Option::is_none")] 74 79 pub seen_at: Option<String>, 80 + /// Present when this was a role mention: the display name of the role that 81 + /// triggered it, so clients can render "mentioned via @<name>". 82 + #[serde(rename = "mentionRoleName", skip_serializing_if = "Option::is_none")] 83 + pub mention_role_name: Option<String>, 75 84 /// Inline message body. `None` if the underlying message has been deleted 76 85 /// from the AppView cache. 77 86 #[serde(skip_serializing_if = "Option::is_none")] ··· 89 98 channel_uri: row.channel_uri, 90 99 indexed_at: row.indexed_at, 91 100 seen_at: row.seen_at, 101 + mention_role_name: row.mention_role_name, 92 102 message, 93 103 } 94 104 } ··· 121 131 out 122 132 } 123 133 134 + /// Extracts every mentioned role URI from the facets of a message. Returns URIs 135 + /// in the order they first appear, deduplicated. 136 + pub fn extract_mentioned_roles(facets: &[Value]) -> Vec<String> { 137 + let mut out: Vec<String> = Vec::new(); 138 + let mut seen: HashSet<String> = HashSet::new(); 139 + for facet in facets { 140 + let Some(features) = facet.get("features").and_then(|f| f.as_array()) else { 141 + continue; 142 + }; 143 + for feature in features { 144 + let Some(kind) = feature.get("$type").and_then(|t| t.as_str()) else { 145 + continue; 146 + }; 147 + if kind != FACET_ROLE_TYPE { 148 + continue; 149 + } 150 + let Some(role) = feature.get("role").and_then(|d| d.as_str()) else { 151 + continue; 152 + }; 153 + if seen.insert(role.to_string()) { 154 + out.push(role.to_string()); 155 + } 156 + } 157 + } 158 + out 159 + } 160 + 161 + /// Expands the role mentions in a message into the members to notify, each 162 + /// paired with the display name of the role that pinged them (for attribution). 163 + /// 164 + /// A role is only expanded when it is `mentionable`, or — for roles not marked 165 + /// mentionable — when the message author holds the `mention.roles` permission. 166 + /// Role URIs whose authority doesn't match the message's community are ignored, 167 + /// so a message can't ping roles from an unrelated community. When a member 168 + /// holds more than one mentioned role, they are attributed to the first such 169 + /// role in the message's facet order. 170 + async fn expand_role_mention_recipients( 171 + db: &DatabaseConnection, 172 + author_did: &str, 173 + channel_uri: &str, 174 + role_uris: &[String], 175 + ) -> Result<Vec<(String, String)>, DbErr> { 176 + let Some(channel) = AtUri::parse(channel_uri) else { 177 + return Ok(Vec::new()); 178 + }; 179 + let community_authority = channel.authority; 180 + let community_uri = format!("at://{community_authority}/social.colibri.community/self"); 181 + 182 + // Resolved once (and only if a non-mentionable role is encountered), since 183 + // it costs several queries. 184 + let mut author_can_mention_all: Option<bool> = None; 185 + // Authorized roles as (rkey, display name), in message facet order and 186 + // deduplicated — the order decides attribution when a member holds several. 187 + let mut authorized: Vec<(String, String)> = Vec::new(); 188 + let mut authorized_seen: HashSet<String> = HashSet::new(); 189 + 190 + for role_uri in role_uris { 191 + let Some(parsed) = AtUri::parse(role_uri) else { 192 + continue; 193 + }; 194 + if parsed.authority != community_authority || parsed.collection != ROLE_NSID { 195 + continue; 196 + } 197 + let role_rkey = parsed.rkey; 198 + 199 + let Some(role_record) = record_data::Entity::find() 200 + .filter(record_data::Column::Did.eq(&community_authority)) 201 + .filter(record_data::Column::Nsid.eq(ROLE_NSID)) 202 + .filter(record_data::Column::Rkey.eq(&role_rkey)) 203 + .one(db) 204 + .await? 205 + else { 206 + continue; 207 + }; 208 + let Ok(role) = serde_json::from_value::<ColibriRole>(role_record.data) else { 209 + continue; 210 + }; 211 + 212 + if role.mentionable != Some(true) { 213 + if author_can_mention_all.is_none() { 214 + let authz = load_actor_authz(db, &community_uri, author_did).await?; 215 + author_can_mention_all = Some(authz.has(Permission::MentionRoles, None)); 216 + } 217 + if author_can_mention_all != Some(true) { 218 + continue; 219 + } 220 + } 221 + 222 + if authorized_seen.insert(role_rkey.clone()) { 223 + authorized.push((role_rkey, role.name)); 224 + } 225 + } 226 + 227 + if authorized.is_empty() { 228 + return Ok(Vec::new()); 229 + } 230 + 231 + let members = record_data::Entity::find() 232 + .filter(record_data::Column::Did.eq(&community_authority)) 233 + .filter(record_data::Column::Nsid.eq(MEMBER_NSID)) 234 + .all(db) 235 + .await?; 236 + 237 + let mut out: Vec<(String, String)> = Vec::new(); 238 + let mut seen: HashSet<String> = HashSet::new(); 239 + for record in members { 240 + let Ok(member) = serde_json::from_value::<ColibriMember>(record.data) else { 241 + continue; 242 + }; 243 + if member.subject == author_did { 244 + continue; 245 + } 246 + let Some((_, role_name)) = authorized 247 + .iter() 248 + .find(|(rkey, _)| member.roles.contains(rkey)) 249 + else { 250 + continue; 251 + }; 252 + if seen.insert(member.subject.clone()) { 253 + out.push((member.subject, role_name.clone())); 254 + } 255 + } 256 + Ok(out) 257 + } 258 + 124 259 /// Looks up the author DID of a parent message. 125 260 /// Returns `None` if the parent is not in the cache. 126 261 /// ··· 172 307 message_uri: &str, 173 308 message: &ColibriMessage, 174 309 ) -> Result<Vec<IndexedNotification>, DbErr> { 175 - let mut recipients: Vec<(String, &'static str)> = Vec::new(); 310 + // (recipient DID, kind, mention role name). The role name is only set for 311 + // role mentions; direct mentions and replies leave it `None`. 312 + let mut recipients: Vec<(String, &'static str, Option<String>)> = Vec::new(); 176 313 let mut seen: HashSet<(String, &'static str)> = HashSet::new(); 177 314 178 315 if let Some(facets) = message.facets.as_ref() { ··· 182 319 } 183 320 let key = (did.clone(), KIND_MENTION); 184 321 if seen.insert(key.clone()) { 185 - recipients.push(key); 322 + recipients.push((did, KIND_MENTION, None)); 323 + } 324 + } 325 + 326 + // Direct mentions are added first, so a member both `@`-mentioned and 327 + // role-mentioned keeps the direct mention (no role attribution). 328 + let role_uris = extract_mentioned_roles(facets); 329 + if !role_uris.is_empty() { 330 + let role_recipients = 331 + expand_role_mention_recipients(db, author_did, &message.channel, &role_uris) 332 + .await?; 333 + for (did, role_name) in role_recipients { 334 + let key = (did.clone(), KIND_MENTION); 335 + if seen.insert(key.clone()) { 336 + recipients.push((did, KIND_MENTION, Some(role_name))); 337 + } 186 338 } 187 339 } 188 340 } ··· 191 343 && let Some(parent_author) = fetch_parent_author(db, &message.channel, parent_rkey).await? 192 344 && parent_author != author_did 193 345 { 194 - let key = (parent_author, KIND_REPLY); 195 - if seen.insert(key.clone()) { 196 - recipients.push(key); 346 + let key = (parent_author.clone(), KIND_REPLY); 347 + if seen.insert(key) { 348 + recipients.push((parent_author, KIND_REPLY, None)); 197 349 } 198 350 } 199 351 ··· 203 355 204 356 let now = current_iso8601_utc(); 205 357 let mut inserted = Vec::new(); 206 - for (recipient_did, kind) in recipients { 358 + for (recipient_did, kind, mention_role_name) in recipients { 207 359 let row = notifications::ActiveModel { 208 360 recipient_did: ActiveValue::Set(recipient_did.clone()), 209 361 kind: ActiveValue::Set(kind.to_string()), ··· 212 364 channel_uri: ActiveValue::Set(message.channel.clone()), 213 365 indexed_at: ActiveValue::Set(now.clone()), 214 366 seen_at: ActiveValue::Set(None), 367 + mention_role_name: ActiveValue::Set(mention_role_name), 215 368 ..Default::default() 216 369 }; 217 370 ··· 508 661 } 509 662 ]); 510 663 assert!(extract_mentioned_dids(facets.as_array().unwrap()).is_empty()); 664 + } 665 + 666 + #[test] 667 + fn extract_mentioned_roles_pulls_role_uri_from_role_features() { 668 + let facets = json!([ 669 + { 670 + "index": {"byteStart": 0, "byteEnd": 5}, 671 + "features": [ 672 + {"$type": "social.colibri.richtext.facet#role", "role": "at://did:plc:c/social.colibri.role/mods"} 673 + ] 674 + }, 675 + { 676 + "index": {"byteStart": 10, "byteEnd": 13}, 677 + "features": [ 678 + {"$type": "social.colibri.richtext.facet#mention", "did": "did:plc:alice"} 679 + ] 680 + }, 681 + { 682 + "index": {"byteStart": 20, "byteEnd": 25}, 683 + "features": [ 684 + {"$type": "social.colibri.richtext.facet#role", "role": "at://did:plc:c/social.colibri.role/vips"}, 685 + {"$type": "social.colibri.richtext.facet#role", "role": "at://did:plc:c/social.colibri.role/mods"} 686 + ] 687 + } 688 + ]); 689 + let roles = extract_mentioned_roles(facets.as_array().unwrap()); 690 + assert_eq!( 691 + roles, 692 + vec![ 693 + String::from("at://did:plc:c/social.colibri.role/mods"), 694 + String::from("at://did:plc:c/social.colibri.role/vips"), 695 + ] 696 + ); 697 + } 698 + 699 + #[test] 700 + fn extract_mentioned_roles_ignores_non_role_features_and_missing_role() { 701 + let facets = json!([ 702 + { 703 + "features": [ 704 + {"$type": "social.colibri.richtext.facet#mention", "did": "did:plc:alice"} 705 + ] 706 + }, 707 + { 708 + "features": [ 709 + {"$type": "social.colibri.richtext.facet#role"} 710 + ] 711 + } 712 + ]); 713 + assert!(extract_mentioned_roles(facets.as_array().unwrap()).is_empty()); 511 714 } 512 715 513 716 #[test]
+4
src/lib/permissions.rs
··· 26 26 ModerationViewLog, 27 27 ApprovalManage, 28 28 VoiceModerate, 29 + MentionRoles, 29 30 } 30 31 31 32 impl Permission { ··· 49 50 Permission::ModerationViewLog => "moderation.viewLog", 50 51 Permission::ApprovalManage => "approval.manage", 51 52 Permission::VoiceModerate => "voice.moderate", 53 + Permission::MentionRoles => "mention.roles", 52 54 } 53 55 } 54 56 ··· 82 84 Permission::ModerationViewLog, 83 85 Permission::ApprovalManage, 84 86 Permission::VoiceModerate, 87 + Permission::MentionRoles, 85 88 ] 86 89 } 87 90 } ··· 119 122 Permission::ModerationViewLog, 120 123 Permission::ApprovalManage, 121 124 Permission::VoiceModerate, 125 + Permission::MentionRoles, 122 126 ]; 123 127 124 128 let strings: HashSet<&str> = all.iter().map(|p| p.as_str()).collect();
+6 -3
src/lib/push_send.rs
··· 159 159 } 160 160 161 161 let title = match notification.row.kind.as_str() { 162 - "reply" => "New reply", 163 - _ => "New mention", 162 + "reply" => String::from("New reply"), 163 + _ => match notification.row.mention_role_name.as_deref() { 164 + Some(role) => format!("Mentioned via @{role}"), 165 + None => String::from("New mention"), 166 + }, 164 167 }; 165 168 let payload = PushPayload { 166 - title: String::from(title), 169 + title, 167 170 body: redact_spoilers(&notification.message.text, &notification.message.facets), 168 171 tag: notification.row.message_uri.clone(), 169 172 data: PushData {
+4
src/models/notifications.rs
··· 21 21 pub indexed_at: String, 22 22 #[sea_orm(column_type = "Text", nullable)] 23 23 pub seen_at: Option<String>, 24 + /// Display name of the role whose mention triggered this notification, if it 25 + /// was a role mention rather than a direct mention. Captured at index time. 26 + #[sea_orm(column_type = "Text", nullable)] 27 + pub mention_role_name: Option<String>, 24 28 } 25 29 26 30 impl ActiveModelBehavior for ActiveModel {}
+1
src/xrpc/social/colibri/notification/get_unseen_handler.rs
··· 104 104 channel_uri: String::from("at://did:plc:owner/social.colibri.channel/chan-a"), 105 105 indexed_at: String::from("2026-05-14T00:00:00Z"), 106 106 seen_at: None, 107 + mention_role_name: None, 107 108 } 108 109 } 109 110
+1
src/xrpc/social/colibri/notification/list_notifications_handler.rs
··· 134 134 channel_uri: String::from("at://did:plc:owner/social.colibri.channel.text/chan-a"), 135 135 indexed_at: String::from("2026-05-14T00:00:00Z"), 136 136 seen_at: None, 137 + mention_role_name: None, 137 138 } 138 139 } 139 140
+1
src/xrpc/social/colibri/sync/subscribe_events_handler.rs
··· 363 363 author_did: indexed.row.author_did, 364 364 channel_uri: indexed.row.channel_uri, 365 365 indexed_at: indexed.row.indexed_at, 366 + mention_role_name: indexed.row.mention_role_name, 366 367 message: NotificationEventMessage { 367 368 text: indexed.message.text, 368 369 facets: indexed.message.facets,