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