Skip to main content

cloudillo_action/
forward.rs

1//! Action forwarding to connected WebSocket clients
2//!
3//! This module provides functionality to forward actions to users connected via WebSocket.
4//! It's used for real-time notification of new actions (messages, reactions, etc.).
5//!
6//! # Usage
7//!
8//! - `forward_action`: Called after an action is created or received to notify connected users
9
10use crate::prelude::*;
11use cloudillo_core::ws_broadcast::{BroadcastMessage, DeliveryResult};
12use cloudillo_types::meta_adapter::AttachmentView;
13use cloudillo_types::types::TnId;
14use serde_json::json;
15
16/// Result of forwarding an action
17#[derive(Debug, Clone)]
18pub struct ForwardResult {
19	/// Whether the action was delivered to at least one WebSocket connection
20	pub delivered: bool,
21	/// Number of connections that received the action
22	pub connection_count: usize,
23	/// Whether the target user(s) are offline (for push notification decision)
24	pub user_offline: bool,
25}
26
27/// Parameters for forwarding an action
28#[derive(Debug, Clone)]
29pub struct ForwardActionParams<'a> {
30	pub action_id: &'a str,
31	pub temp_id: Option<&'a str>,
32	pub issuer_tag: &'a str,
33	pub audience_tag: Option<&'a str>,
34	pub action_type: &'a str,
35	pub sub_type: Option<&'a str>,
36	pub content: Option<&'a serde_json::Value>,
37	pub attachments: Option<&'a [AttachmentView]>,
38	pub status: Option<&'a str>,
39}
40
41/// Forward an action to WebSocket clients
42///
43/// This forwards actions to the audience user if present.
44/// Works for both outbound (local user creates) and inbound (federation) actions.
45/// Returns information about delivery status for push notification decision.
46pub async fn forward_action(
47	app: &App,
48	tn_id: TnId,
49	params: &ForwardActionParams<'_>,
50) -> ForwardResult {
51	// Only forward if there's a specific audience
52	if let Some(audience) = params.audience_tag {
53		let action_msg = build_action_message(params);
54		forward_to_user(app, tn_id, audience, action_msg).await
55	} else {
56		// No audience - nothing to forward via WebSocket
57		ForwardResult { delivered: false, connection_count: 0, user_offline: false }
58	}
59}
60
61/// Forward an outbound action (created by local user) to WebSocket clients
62///
63/// This should be called after the action is created and hooks are executed.
64/// Returns information about delivery status for push notification decision.
65pub async fn forward_outbound_action(
66	app: &App,
67	tn_id: TnId,
68	params: &ForwardActionParams<'_>,
69) -> ForwardResult {
70	forward_action(app, tn_id, params).await
71}
72
73/// Forward an inbound action (received from federation) to WebSocket clients
74///
75/// Broadcasts to all connected clients in the tenant.
76/// Any client can filter what they're interested in.
77pub async fn forward_inbound_action(
78	app: &App,
79	tn_id: TnId,
80	params: &ForwardActionParams<'_>,
81) -> ForwardResult {
82	let action_msg = build_action_message(params);
83	let delivered = app.broadcast.send_to_tenant(tn_id, action_msg).await;
84
85	ForwardResult {
86		delivered: delivered > 0,
87		connection_count: delivered,
88		user_offline: delivered == 0,
89	}
90}
91
92/// Forward a message to a specific user
93async fn forward_to_user(
94	app: &App,
95	tn_id: TnId,
96	user_id: &str,
97	msg: BroadcastMessage,
98) -> ForwardResult {
99	match app.broadcast.send_to_user(tn_id, user_id, msg).await {
100		DeliveryResult::Delivered(count) => {
101			tracing::debug!(user_id = %user_id, connections = %count, "Action forwarded to user");
102			ForwardResult { delivered: true, connection_count: count, user_offline: false }
103		}
104		DeliveryResult::UserOffline => {
105			tracing::debug!(user_id = %user_id, "User offline - action not forwarded");
106			ForwardResult { delivered: false, connection_count: 0, user_offline: true }
107		}
108	}
109}
110
111/// Build a BroadcastMessage for an action
112fn build_action_message(params: &ForwardActionParams<'_>) -> BroadcastMessage {
113	BroadcastMessage::new(
114		"ACTION",
115		json!({
116			"actionId": params.action_id,
117			"tempId": params.temp_id,
118			"type": params.action_type,
119			"subType": params.sub_type,
120			"issuer": {
121				"idTag": params.issuer_tag
122			},
123			"audience": params.audience_tag.map(|a| json!({"idTag": a})),
124			"content": params.content,
125			"attachments": params.attachments,
126			"status": params.status,
127		}),
128		"system",
129	)
130}
131
132/// Check if an action type should trigger push notifications
133///
134/// Returns true if the action type is configured to send push notifications
135/// when the user is offline.
136pub fn should_push_notify(action_type: &str, sub_type: Option<&str>) -> bool {
137	// DEL subtypes don't trigger notifications
138	if sub_type.map(|s| s == "DEL").unwrap_or(false) {
139		return false;
140	}
141
142	matches!(
143		action_type,
144		"MSG"    // Direct messages
145		| "CONN" // Connection requests
146		| "FSHR" // File shares
147		| "CMNT" // Comments (on user's posts)
148	)
149}
150
151/// Get the push notification setting key for an action type
152///
153/// Returns the settings key to check whether push notifications are enabled
154/// for this action type.
155pub fn get_push_setting_key(action_type: &str) -> &'static str {
156	match action_type {
157		"MSG" => "notify.push.message",
158		"CONN" => "notify.push.connection",
159		"FSHR" => "notify.push.file_share",
160		"FLLW" => "notify.push.follow",
161		"CMNT" => "notify.push.comment",
162		"REACT" => "notify.push.reaction",
163		"POST" => "notify.push.post",
164		_ => "notify.push", // Fall back to master switch
165	}
166}
167
168#[cfg(test)]
169mod tests {
170	use super::*;
171
172	#[test]
173	fn test_should_push_notify() {
174		// Should notify
175		assert!(should_push_notify("MSG", None));
176		assert!(should_push_notify("CONN", None));
177		assert!(should_push_notify("FSHR", None));
178		assert!(should_push_notify("CMNT", None));
179
180		// Should not notify
181		assert!(!should_push_notify("POST", None));
182		assert!(!should_push_notify("FLLW", None));
183		assert!(!should_push_notify("REACT", None));
184
185		// DEL subtypes don't notify
186		assert!(!should_push_notify("MSG", Some("DEL")));
187		assert!(!should_push_notify("CONN", Some("DEL")));
188	}
189
190	#[test]
191	fn test_get_push_setting_key() {
192		assert_eq!(get_push_setting_key("MSG"), "notify.push.message");
193		assert_eq!(get_push_setting_key("CONN"), "notify.push.connection");
194		assert_eq!(get_push_setting_key("FSHR"), "notify.push.file_share");
195		assert_eq!(get_push_setting_key("UNKNOWN"), "notify.push");
196	}
197}
198
199// vim: ts=4