1use tilo_actions::remote::{CatalogueQuery, ViewResourceQuery};
10use tilo_reference::link::{
11 AppId, HostOrigin, Link, LinkSecrets, LinkTarget, Mode, RecordSegment, ResourceAddress, Scope,
12 WebEntry,
13};
14use tilo_resource::share::{SHARE_INVITE_TYPE, SHARE_TYPE, ShareRequest};
15use ui_core::time::{Duration, Instant};
16use ui_core::{Arrival, CatalogueCall, RemoteActions};
17
18const LOOKUP_TIMEOUT: Duration = Duration::from_secs(30);
20
21pub(crate) const NO_WEB_ENTRY: &str = "This workspace's host serves no web page";
23
24pub(crate) struct Publisher<'a> {
26 pub(crate) app: &'a str,
27 pub(crate) record: Option<&'a str>,
28 pub(crate) root: &'a str,
30}
31
32struct Minting {
34 provider: String,
35 app: AppId,
36 record: RecordSegment,
37 resource: Option<ResourceAddress>,
38 view: Option<String>,
39 mode: Mode,
40}
41
42struct Pending {
43 invocation: tilo_actions::InvocationId,
44 minting: Minting,
45 call: CatalogueCall,
46 started: Instant,
47 copy: Option<ui_core::clipboard::ReservedCopy>,
50 copying: Option<(String, ui_core::clipboard::ClipboardCopy)>,
53}
54
55pub(crate) struct ShareLinks {
58 remote: RemoteActions,
59 pending: Vec<Pending>,
60 copy_requested: Vec<(String, ui_core::clipboard::ReservedCopy)>,
64}
65
66impl Default for ShareLinks {
67 fn default() -> Self {
68 Self {
69 remote: RemoteActions::current(),
70 pending: Vec::new(),
71 copy_requested: Vec::new(),
72 }
73 }
74}
75
76pub(crate) fn handles(type_id: &str) -> bool {
77 type_id == SHARE_TYPE || type_id == SHARE_INVITE_TYPE
78}
79
80pub(crate) struct Minted {
82 pub(crate) output: tilo_actions::ActionOutput,
83 pub(crate) copied: bool,
84}
85
86impl ShareLinks {
87 pub(crate) fn remote_mut(&mut self) -> &mut RemoteActions {
88 &mut self.remote
89 }
90
91 pub(crate) fn note_view_invocation(&mut self, invocation: &tilo_actions::ControlInvocation) {
94 if invocation.address.action == tilo_resource::share::SHARE_LINK
95 || invocation.address.action == tilo_resource::share::SHARE_INVITE_LINK
96 {
97 self.copy_requested.push((
98 invocation.address.resource.clone(),
99 ui_core::reserve_clipboard_copy(),
100 ));
101 }
102 }
103
104 pub(crate) fn start(
106 &mut self,
107 invocation: tilo_actions::InvocationId,
108 payload: &[u8],
109 publisher: &Publisher<'_>,
110 may_invite: bool,
111 ) -> Result<Option<Minted>, String> {
112 let request: ShareRequest =
113 tilo_actions::decode_json(payload).map_err(|error| error.to_string())?;
114 let copy = self
115 .copy_requested
116 .iter()
117 .position(|(resource, _)| *resource == request.resource)
118 .map(|at| self.copy_requested.remove(at).1);
119 if request.invite {
120 if !may_invite {
121 return Err("You may not invite people to this workspace".into());
122 }
123 return Err(
126 "Invitations are created from the workspace's members list, not from an app".into(),
127 );
128 }
129 let minting = minting(&request, publisher)?;
130 let call = self.remote.catalogue_page(CatalogueQuery {
131 provider: Some(minting.provider.clone()),
132 view_resource: Some(ViewResourceQuery {
133 id: request.resource,
134 kind: request.kind,
135 }),
136 ..CatalogueQuery::default()
137 });
138 let mut pending = Pending {
139 invocation,
140 minting,
141 call,
142 started: ui_core::time::now(),
143 copy,
144 copying: None,
145 };
146 if let Some(result) = settled(&pending) {
148 let url = result?;
149 let Some(reserved) = pending.copy.take() else {
150 return Ok(Some(finish(&url, None)));
151 };
152 let copy = reserved.fill(&url);
153 if let Some(copied) = copy.outcome() {
154 return Ok(Some(finish(&url, Some(copied))));
155 }
156 pending.copying = Some((url, copy));
157 }
158 self.pending.push(pending);
159 Ok(None)
160 }
161
162 pub(crate) fn poll(
164 &mut self,
165 invocation: tilo_actions::InvocationId,
166 cancellation_requested: bool,
167 ) -> Option<(tilo_actions::InvocationUpdate, bool)> {
168 let at = self
169 .pending
170 .iter()
171 .position(|pending| pending.invocation == invocation)?;
172 let pending = self.pending.get_mut(at)?;
173 if cancellation_requested {
174 self.pending.remove(at);
175 return Some((
176 tilo_actions::InvocationUpdate::Cancelled {
177 invocation,
178 message: None,
179 },
180 false,
181 ));
182 }
183 if pending.copying.is_none() {
184 let result = settled(pending).or_else(|| {
185 (ui_core::time::now().saturating_duration_since(pending.started) >= LOOKUP_TIMEOUT)
186 .then(|| Err("The workspace's host did not answer".to_owned()))
187 })?;
188 match result {
189 Ok(url) => {
190 let Some(reserved) = pending.copy.take() else {
191 self.pending.remove(at);
192 return Some(succeeded(invocation, finish(&url, None)));
193 };
194 let copy = reserved.fill(&url);
195 pending.copying = Some((url, copy));
196 }
197 Err(message) => {
198 self.pending.remove(at);
199 return Some((
200 tilo_actions::InvocationUpdate::Failed {
201 invocation,
202 error: tilo_actions::ActionFailure {
203 code: "share_link".into(),
204 message,
205 details: None,
206 retryable: true,
207 },
208 },
209 false,
210 ));
211 }
212 }
213 }
214 let (url, copy) = pending.copying.as_ref()?;
215 let minted = finish(url, Some(copy.outcome()?));
216 self.pending.remove(at);
217 Some(succeeded(invocation, minted))
218 }
219}
220
221fn settled(pending: &Pending) -> Option<Result<String, String>> {
223 pending.call.with(|call| match call {
224 Arrival::Pending { .. } => None,
225 Arrival::Failed(error) => Some(Err(format!(
226 "The workspace's host would not look the resource up: {error}"
227 ))),
228 Arrival::Ready(page) => Some(render(&pending.minting, page)),
229 })
230}
231
232fn succeeded(
233 invocation: tilo_actions::InvocationId,
234 minted: Minted,
235) -> (tilo_actions::InvocationUpdate, bool) {
236 (
237 tilo_actions::InvocationUpdate::Succeeded {
238 invocation,
239 output: minted.output,
240 },
241 minted.copied,
242 )
243}
244
245fn finish(url: &str, copied: Option<bool>) -> Minted {
248 Minted {
249 output: tilo_actions::ActionOutput {
250 value: Some(match copied {
251 Some(copied) => serde_json::json!({ "url": url, "copied": copied }),
252 None => serde_json::json!({ "url": url }),
253 }),
254 resources: Vec::new(),
255 },
256 copied: copied.unwrap_or(false),
257 }
258}
259
260fn minting(request: &ShareRequest, publisher: &Publisher<'_>) -> Result<Minting, String> {
262 let app = AppId::new(publisher.app).map_err(|error| error.to_string())?;
263 let record =
264 RecordSegment::new(publisher.record.unwrap_or("")).map_err(|error| error.to_string())?;
265 let provider = format!("record:{}:{}", app.as_str(), record.provider_part());
266 let args = &request.args;
267 let text = |name: &str| args.get(name).and_then(serde_json::Value::as_str);
268 let view = match text("view") {
269 Some(id) => request
270 .views
271 .iter()
272 .find(|view| view.id == id)
273 .ok_or_else(|| format!("This has no view `{id}`"))?,
274 None => request
275 .views
276 .iter()
277 .find(|view| view.primary)
278 .or_else(|| request.views.first())
279 .ok_or("This has no view to link to")?,
280 };
281 let offset = |name: &str| -> Result<Option<usize>, String> {
282 args.get(name)
283 .filter(|value| !value.is_null())
284 .map(|value| {
285 value
286 .as_u64()
287 .and_then(|offset| usize::try_from(offset).ok())
288 .ok_or_else(|| format!("{name} is not a position in the text"))
289 })
290 .transpose()
291 };
292 let span = match (offset("start")?, offset("end")?) {
293 (None, None) => None,
294 (Some(start), Some(end)) => {
295 if !view.range {
296 return Err(format!("The view `{}` does not open at a range", view.id));
297 }
298 let unit = match text("unit").unwrap_or("chars") {
299 "chars" => tilo_reference::SpanUnit::Chars,
300 "bytes" => tilo_reference::SpanUnit::Bytes,
301 other => return Err(format!("Unknown unit `{other}`")),
302 };
303 Some(tilo_reference::Span::new(start, end, unit))
304 }
305 _ => return Err("A range needs both start and end".into()),
306 };
307 let resource =
308 (request.resource != publisher.root || span.is_some()).then(|| ResourceAddress {
309 kind: request.kind.clone(),
310 id: request.resource.clone(),
311 span,
312 });
313 let mode = match text("mode").unwrap_or("app") {
314 "app" => Mode::App,
315 "desktop" => Mode::Desktop,
316 other => return Err(format!("Unknown mode `{other}`")),
317 };
318 Ok(Minting {
319 provider,
320 app,
321 record,
322 resource,
323 view: (!view.primary).then(|| view.id.clone()),
324 mode,
325 })
326}
327
328fn render(minting: &Minting, page: &tilo_actions::remote::CataloguePage) -> Result<String, String> {
330 let locator = page
331 .locator
332 .as_ref()
333 .ok_or("The workspace's host did not say where the resource is")?;
334 render_at(minting, locator)
335}
336
337pub fn host_link(
341 request: &ShareRequest,
342 app: &str,
343 record: &str,
344 locator: &tilo_actions::remote::ResourceViewLocator,
345) -> Result<String, String> {
346 if request.invite {
347 return Err(
348 "Invitations are created from the workspace's members list, not from an app".into(),
349 );
350 }
351 let publisher = Publisher {
354 app,
355 record: Some(record),
356 root: "",
357 };
358 render_at(&minting(request, &publisher)?, locator)
359}
360
361fn render_at(
362 minting: &Minting,
363 locator: &tilo_actions::remote::ResourceViewLocator,
364) -> Result<String, String> {
365 if locator.provider != minting.provider {
366 return Err("The workspace's host answered for another record".into());
367 }
368 let entry = locator.web_entry.as_deref().ok_or(NO_WEB_ENTRY)?;
369 let entry = WebEntry::new(entry).map_err(|error| error.to_string())?;
370 let workspace = locator
371 .workspace
372 .parse()
373 .map_err(|_| "The workspace's host named no workspace a link can carry")?;
374 let host = HostOrigin::new(&locator.origin).map_err(|error| error.to_string())?;
375 let target = LinkTarget {
376 scope: Scope::Room {
377 host,
378 id: workspace,
379 slug: None,
380 },
381 app: Some(minting.app.clone()),
382 record: Some(minting.record.clone()),
383 resource: minting.resource.clone(),
384 view: minting.view.clone(),
385 discuss: None,
386 };
387 Link {
388 mode: minting.mode,
389 target,
390 secrets: LinkSecrets::default(),
391 }
392 .to_url(&entry)
393 .map_err(|error| error.to_string())
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use tilo_actions::remote::{CataloguePage, ResourceViewLocator};
400 use tilo_resource::share::SharedView;
401
402 const WORKSPACE: &str = "6f1c2b9e-0d4a-4c1e-9b7a-2f3d1e5c8a90";
403
404 fn request(args: &serde_json::Value) -> ShareRequest {
405 ShareRequest {
406 resource: "chat.message:01JA".into(),
407 kind: "chat.message".into(),
408 views: vec![
409 SharedView {
410 id: "message".into(),
411 primary: true,
412 range: true,
413 },
414 SharedView {
415 id: "thread".into(),
416 primary: false,
417 range: false,
418 },
419 ],
420 invite: false,
421 args: args.as_object().cloned().unwrap_or_default(),
422 }
423 }
424
425 fn page(entry: Option<&str>) -> CataloguePage {
426 CataloguePage {
427 locator: Some(ResourceViewLocator {
428 origin: "https://ada.tilo.cloud".into(),
429 workspace: WORKSPACE.into(),
430 host_device: "ab".repeat(32),
431 provider: "record:so.tilo.chat:".into(),
432 web_entry: entry.map(str::to_owned),
433 }),
434 ..CataloguePage::default()
435 }
436 }
437
438 const CHAT: Publisher<'static> = Publisher {
439 app: "so.tilo.chat",
440 record: Some("@tilo:package-primary"),
441 root: "chat",
442 };
443
444 fn url(args: &serde_json::Value) -> Result<String, String> {
445 render(
446 &minting(&request(args), &CHAT)?,
447 &page(Some("https://tilo.so/")),
448 )
449 }
450
451 #[test]
452 fn links_render_the_grammar_under_the_announced_entry() {
453 assert_eq!(
454 url(&serde_json::json!({})).unwrap(),
455 format!(
456 "https://tilo.so/app/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-/chat.message:01JA"
457 )
458 );
459 assert_eq!(
460 url(&serde_json::json!({"start": 6, "end": 9, "mode": "desktop"})).unwrap(),
461 format!(
462 "https://tilo.so/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-/chat.message:01JA?chars=6-9"
463 )
464 );
465 assert!(
466 url(&serde_json::json!({"view": "thread"}))
467 .unwrap()
468 .ends_with("chat.message:01JA?view=thread")
469 );
470 }
471
472 #[test]
473 fn what_cannot_be_linked_is_refused_with_its_reason() {
474 assert!(url(&serde_json::json!({"view": "thread", "start": 1, "end": 2})).is_err());
475 assert!(url(&serde_json::json!({"start": 1})).is_err());
476 assert!(url(&serde_json::json!({"view": "nope"})).is_err());
477 let minting = minting(&request(&serde_json::json!({})), &CHAT).unwrap();
478 assert_eq!(render(&minting, &page(None)).unwrap_err(), NO_WEB_ENTRY);
479 let mut other = page(Some("https://tilo.so/"));
480 if let Some(locator) = other.locator.as_mut() {
481 locator.provider = "record:so.tilo.notes:".into();
482 }
483 assert!(render(&minting, &other).is_err());
484 }
485
486 #[test]
487 fn a_link_to_the_root_names_the_record() {
488 let mut root = request(&serde_json::json!({}));
489 root.resource = "chat".into();
490 let minting = minting(&root, &CHAT).unwrap();
491 assert_eq!(
492 render(&minting, &page(Some("https://tilo.so/"))).unwrap(),
493 format!("https://tilo.so/app/#/@ada.tilo.cloud/w/{WORKSPACE}/so.tilo.chat/-")
494 );
495 }
496}