1use std::{
5 cell::{Cell, RefCell},
6 rc::Rc,
7};
8
9use crate::feedback::{Composer, Consent, DeliveryState, PickedReport, RecordingScope, Subject};
10use crate::i18n::EmbeddedCatalog;
11use crate::prelude::*;
12
13#[path = "feedback_composer_report.rs"]
14mod report;
15
16static CATALOG: EmbeddedCatalog =
17 EmbeddedCatalog::new("en-US", &[("en-US", include_str!("feedback_composer.ftl"))]);
18
19#[derive(Clone)]
21pub struct FeedbackServices {
22 controller: Rc<RefCell<Composer>>,
23 presentation: Rc<RefCell<Option<Presentation>>>,
24 mounted: Rc<Cell<bool>>,
25 models: Rc<RefCell<(crate::models::Models, String)>>,
28 pass: report::Pass,
29 awaiting: Rc<Cell<Option<crate::time::Instant>>>,
31}
32
33const RECEIPT_CHECK_FIRST: crate::time::Duration = crate::time::Duration::from_secs(1);
36const RECEIPT_CHECK_LAST: crate::time::Duration = crate::time::Duration::from_secs(10);
38
39#[derive(Clone)]
41struct Presentation {
42 status: LocalState<String>,
43 subject: LocalState<Option<Subject>>,
44 shown: LocalState<String>,
45 fields: LocalState<Vec<crate::feedback::ContextField>>,
46 pixels: LocalState<Option<Vec<u8>>>,
47 offer: LocalState<Option<RecordingScope>>,
49 recorded: LocalState<Option<RecordingScope>>,
51 picks: LocalState<Option<Vec<String>>>,
53 review: LocalState<Option<report::ReviewShown>>,
54 notes: Rc<Vec<LocalState<String>>>,
56 report: Rc<ReportShown>,
59}
60
61type ReportShown = dyn Fn(&Composer) -> (Option<Vec<String>>, Option<report::ReviewShown>);
63
64impl Presentation {
65 fn show(&self, composer: &Composer) {
68 self.subject.set(composer.subject().cloned());
69 self.offer.set(composer.recording_scope().cloned());
70 let (picks, review) = (self.report)(composer);
71 self.picks.set(picks);
72 self.review.set(review);
73 if let Some(review) = composer.preview() {
74 let feedback = review.feedback();
75 self.shown.set(feedback.text.clone());
76 self.fields.set(feedback.context.clone());
77 self.pixels
78 .set(review.screenshot().map(|image| image.png().to_vec()));
79 self.recorded
80 .set(review.recording().map(crate::feedback::Recorded::scope));
81 } else {
82 self.shown.set(String::new());
83 self.fields.set(Vec::new());
84 self.pixels.set(None);
85 self.recorded.set(None);
86 }
87 }
88}
89
90impl FeedbackServices {
91 #[must_use]
96 pub fn next_wake_deadline(&self) -> Option<crate::time::Instant> {
97 if self.pass.try_borrow().is_ok_and(|pass| pass.is_some()) {
98 return crate::time::now().checked_add(crate::time::Duration::from_millis(50));
99 }
100 let controller = self.controller.try_borrow().ok()?;
101 let state = controller.state().ok()?;
102 if state != Some(DeliveryState::AwaitingReceipt) {
103 self.awaiting.set(None);
104 }
105 match state {
106 Some(DeliveryState::Saving) => {
107 crate::time::now().checked_add(crate::time::Duration::from_millis(100))
108 }
109 Some(DeliveryState::AwaitingReceipt) => {
110 let now = crate::time::now();
111 let since = self.awaiting.get().unwrap_or(now);
112 self.awaiting.set(Some(since));
113 let delay = now
114 .saturating_duration_since(since)
115 .checked_div(4)
116 .unwrap_or_default()
117 .clamp(RECEIPT_CHECK_FIRST, RECEIPT_CHECK_LAST);
118 now.checked_add(delay)
119 }
120 _ => None,
121 }
122 }
123
124 #[must_use]
125 pub fn new(controller: Rc<RefCell<Composer>>) -> Self {
126 Self {
127 controller,
128 presentation: Rc::default(),
129 mounted: Rc::default(),
130 models: Rc::default(),
131 pass: Rc::default(),
132 awaiting: Rc::default(),
133 }
134 }
135
136 pub fn set_models(&self, models: crate::models::Models, name: impl Into<String>) {
140 *self.models.borrow_mut() = (models, name.into());
141 }
142
143 pub fn begin_report(&self, picked: PickedReport) -> Result<(), crate::feedback::Error> {
146 let mut controller = self
147 .controller
148 .try_borrow_mut()
149 .map_err(|_| crate::feedback::Error::Invalid("composer busy"))?;
150 controller.begin_report(picked);
151 self.pass.borrow_mut().take();
152 if let Ok(presentation) = self.presentation.try_borrow()
153 && let Some(presentation) = presentation.as_ref()
154 {
155 for note in presentation.notes.iter() {
156 note.set(String::new());
157 }
158 presentation.show(&controller);
159 }
160 Ok(())
161 }
162
163 pub fn about(&self, subject: Option<Subject>) -> Result<(), crate::feedback::Error> {
166 let mut controller = self
167 .controller
168 .try_borrow_mut()
169 .map_err(|_| crate::feedback::Error::Invalid("composer busy"))?;
170 controller.about(subject);
171 if let Ok(presentation) = self.presentation.try_borrow()
172 && let Some(presentation) = presentation.as_ref()
173 {
174 presentation.show(&controller);
175 }
176 Ok(())
177 }
178
179 pub fn pump(&self) -> Result<(), crate::feedback::Error> {
183 self.poll_model();
184 let result = self
185 .controller
186 .try_borrow_mut()
187 .map_err(|_| crate::feedback::Error::Invalid("composer busy"))
188 .and_then(|mut controller| controller.pump());
189 if let Ok(presentation) = self.presentation.try_borrow()
190 && let Some(presentation) = presentation.as_ref()
191 {
192 if let Err(error) = &result {
193 presentation.status.set(error_label(error).to_owned());
194 } else {
195 restore_progress(self, &presentation.status);
196 }
197 }
198 result
199 }
200}
201
202impl FeedbackServices {
203 fn poll_model(&self) {
205 let (Ok(mut pass), Ok(mut controller)) =
206 (self.pass.try_borrow_mut(), self.controller.try_borrow_mut())
207 else {
208 return;
209 };
210 let Some(running) = pass.as_mut() else {
211 return;
212 };
213 if running(&mut controller) {
214 *pass = None;
215 if let Ok(presentation) = self.presentation.try_borrow()
216 && let Some(presentation) = presentation.as_ref()
217 {
218 presentation.show(&controller);
219 }
220 }
221 }
222}
223
224struct PresentationLease(FeedbackServices);
227
228impl Drop for PresentationLease {
229 fn drop(&mut self) {
230 self.0.presentation.borrow_mut().take();
231 self.0.mounted.set(false);
232 if let Ok(mut controller) = self.0.controller.try_borrow_mut() {
233 controller.abandon_review();
234 }
235 }
236}
237
238#[derive(Clone, Debug, Default, PartialEq, Model)]
241#[model(check = FeedbackDraft::valid)]
242#[allow(
243 clippy::struct_excessive_bools,
244 reason = "each is one checkbox of the composer, set on its own"
245)]
246pub struct FeedbackDraft {
247 pub issue: String,
249 pub include_context: bool,
251 pub include_screenshot: bool,
253 pub slow: bool,
256 pub include_recording: bool,
258}
259
260impl FeedbackDraft {
261 fn valid(&self) -> Result<(), tilo_resource::Refusal> {
262 if self.issue.len() > 8_000 {
263 return Err(tilo_resource::Refusal::new("feedback-issue-too-long"));
264 }
265 Ok(())
266 }
267}
268
269#[resource(kind = "tilo.feedback-draft", label = "Feedback")]
270impl FeedbackDraft {}
271
272#[derive(Clone, Debug, Effect)]
273#[effect(operation = "feedback.compose")]
274pub enum FeedbackEffect {
275 Review,
277 Submit,
279 Retry,
281 Redact {
283 #[param("Reviewed field key")]
284 key: String,
285 #[param("UTF-8 byte start")]
286 start: usize,
287 #[param("UTF-8 byte end")]
288 end: usize,
289 },
290 RemoveScreenshot,
292 RemoveRecording,
294 Toggle {
296 #[param("Which hidden span, by its place in the review")]
297 index: usize,
298 },
299}
300
301pub struct FeedbackComposer;
303
304#[must_use]
308#[inline(never)]
309pub fn definition() -> &'static crate::application::ApplicationDefinition {
310 crate::framework::definition::<FeedbackComposer>()
311}
312
313pub fn prepare(
317 services: Option<FeedbackServices>,
318 device: &str,
319 author: &str,
320 instance: u64,
321) -> Result<AppComponent, crate::launch::LaunchError> {
322 let definition = definition();
323 let store = Rc::new(crate::feedback::MemoryStore::new());
324 let workspace = "feedback-private-draft";
325 let records = crate::record::RecordOwners::with_store(store.clone())
326 .open(FeedbackComposer::ID, workspace)
327 .map_err(|error| crate::launch::LaunchError::Operation(error.to_string()))?;
328 let views = Rc::new(
329 InstanceViewStore::with_store(store, device, workspace)
330 .map_err(|error| crate::launch::LaunchError::Operation(error.to_string()))?,
331 );
332 let target = LaunchTarget::New;
333 let permissions = crate::permissions::SharedPermissions::new(
334 crate::permissions::PermissionContext::member(author, crate::permissions::Role::Owner),
335 );
336 let mut context = HostContext::prepare(
337 definition,
338 HostContextInputs {
339 records,
340 views,
341 device_id: device.to_owned(),
342 instance_id: instance,
343 permissions,
344 author: crate::permissions::UserId(author.to_owned()),
345 action_context: tilo_actions::ActionContext::default(),
346 },
347 &target,
348 )?;
349 context.provide_private();
350 if let Some(services) = services {
351 if services.mounted.replace(true) {
352 return Err(crate::launch::LaunchError::Operation(
353 "feedback-busy".into(),
354 ));
355 }
356 context.lend(PresentationLease(services.clone()));
357 context.lend(services);
358 }
359 definition.instantiate(context, target, RestoreInput::None)
360}
361
362impl ResourceApp for FeedbackComposer {
363 type Resource = FeedbackDraft;
364 const ID: &'static str = "so.tilo.feedback-composer";
365 const TITLE: &'static str = "Feedback";
366 const DOCUMENT_HISTORY: bool = false;
367 const PRIVATE_WINDOWS: bool = true;
368 const BEHAVIOUR: ComponentBehaviour = ComponentBehaviour {
369 capture_protected: true,
370 ..ComponentBehaviour::DEFAULT
371 };
372
373 fn catalog() -> Option<&'static EmbeddedCatalog> {
374 Some(&CATALOG)
375 }
376
377 fn view(cx: &mut ViewCx<FeedbackDraft>) -> Element {
378 let presentation = Presentation {
379 status: LocalState::new("feedback-review-first".to_owned()),
380 subject: LocalState::new(None),
381 shown: LocalState::new(String::new()),
382 fields: LocalState::new(Vec::new()),
383 pixels: LocalState::new(None),
384 offer: LocalState::new(None),
385 recorded: LocalState::new(None),
386 picks: LocalState::new(None),
387 review: LocalState::new(None),
388 notes: Rc::new(
389 (0..crate::feedback::report::MAX_PICKS)
390 .map(|_| LocalState::new(String::new()))
391 .collect(),
392 ),
393 report: Rc::new(|composer: &Composer| {
394 (report::titles(composer), report::ReviewShown::of(composer))
395 }),
396 };
397 let screenshots = install_effects(cx, &presentation);
398 let shown = presentation.clone();
399 cx.switch(&presentation.picks, move |part, picks| match picks {
400 Some(titles) => report::sheet(part, &shown, titles),
401 None => typed(part, &shown, screenshots),
402 })
403 }
404}
405
406fn typed(cx: &ViewCx<FeedbackDraft>, presentation: &Presentation, screenshots: bool) -> Element {
408 let subject = about(cx, &presentation.subject);
409 let fields_view = reviewed_fields(cx, &presentation.fields);
410 let preview = cx.switch(&presentation.shown, |_, shown| {
411 Box::new(
412 Text::new("feedback-reviewed-text")
413 .with_arg("value", shown.clone())
414 .with_id("feedback-preview"),
415 )
416 });
417 let picture = cx.switch(&presentation.pixels, |_, png| {
420 png.as_ref()
421 .and_then(|bytes| crate::widget::Image::from_encoded(bytes).ok())
422 .map_or_else(
423 || ui_core::widget::optional(None),
424 |image| Box::new(image.with_alt("feedback-screenshot-preview")) as Element,
425 )
426 });
427 let mut choices = crate::column![
428 Text::new("feedback-title"),
429 cx.input(FeedbackDraft::paths().issue)
430 .with_id("feedback-issue")
431 .with_placeholder("feedback-describe")
432 .multiline(true),
433 ]
434 .push(subject);
435 if screenshots {
437 choices = choices.push(Box::new(cx.checkbox(
438 "feedback-screenshot",
439 FeedbackDraft::paths().include_screenshot,
440 )));
441 }
442 choices = choices.push(recording_offer(cx, &presentation.offer));
443 let mut sending = group().push(picture);
444 if screenshots {
445 sending = sending.push(Box::new(cx.effect_button(
446 "feedback-remove-screenshot",
447 &FeedbackEffect::RemoveScreenshot,
448 )));
449 }
450 sending = sending.push(cx.switch(&presentation.recorded, |part, scope| {
451 let Some(scope) = scope else {
452 return ui_core::widget::optional(None);
453 };
454 Box::new(
455 group()
456 .push(recording_included(scope))
457 .push(Box::new(part.effect_button(
458 "feedback-remove-recording",
459 &FeedbackEffect::RemoveRecording,
460 ))),
461 ) as Element
462 }));
463 let sending = sending
468 .push(Box::new(ui_core::widget::When::from_state(
469 &presentation.status,
470 |status| status != "feedback-review-first",
471 Box::new(
472 Text::from_state(&presentation.status, Clone::clone)
473 .with_id("feedback-status")
474 .with_live(crate::a11y::Politeness::Polite),
475 ),
476 )))
477 .push(Box::new(
478 cx.effect_button("feedback-submit", &FeedbackEffect::Submit)
479 .role(ui_core::widget::ButtonRole::Primary),
480 ))
481 .push(Box::new(
482 cx.effect_button("feedback-retry", &FeedbackEffect::Retry)
483 .role(ui_core::widget::ButtonRole::Plain),
484 ));
485 Box::new(
486 choices
487 .push(Box::new(Text::new("feedback-privacy")))
488 .push(Box::new(
489 cx.effect_button("feedback-review", &FeedbackEffect::Review),
490 ))
491 .push(preview)
492 .push(fields_view)
493 .push(Box::new(sending)),
494 )
495}
496
497fn about(cx: &ViewCx<FeedbackDraft>, subject: &LocalState<Option<Subject>>) -> Element {
500 cx.switch(subject, |part, subject| {
501 let Some(subject) = subject else {
502 return ui_core::widget::optional(None);
503 };
504 let name = if subject.name.is_empty() {
505 subject.app.clone()
506 } else {
507 subject.name.clone()
508 };
509 Box::new(
510 group()
511 .push(Box::new(
512 Text::new("feedback-about")
513 .with_arg("app", name)
514 .with_id("feedback-about"),
515 ))
516 .push(Box::new(part.checkbox(
517 "feedback-context",
518 FeedbackDraft::paths().include_context,
519 ))),
520 ) as Element
521 })
522}
523
524fn recording_offer(
527 cx: &ViewCx<FeedbackDraft>,
528 offer: &LocalState<Option<RecordingScope>>,
529) -> Element {
530 cx.switch(offer, |part, offer| {
531 let Some(scope) = offer else {
532 return ui_core::widget::optional(None);
533 };
534 Box::new(
535 group()
536 .push(Box::new(part.checkbox(
537 "feedback-recording",
538 FeedbackDraft::paths().include_recording,
539 )))
540 .push(Box::new(
541 with_scope(Text::new("feedback-recording-scope"), scope)
542 .with_id("feedback-recording-scope"),
543 )),
544 ) as Element
545 })
546}
547
548fn recording_included(scope: &RecordingScope) -> Element {
550 Box::new(
551 with_scope(Text::new("feedback-recording-included"), scope)
552 .with_id("feedback-recording-included"),
553 )
554}
555
556fn with_scope(text: Text, scope: &RecordingScope) -> Text {
558 let at = |ms: i64| ui_core::format::time_of(&ui_core::format::UnixSecs(ms.div_euclid(1000)));
559 text.with_arg("app", scope.app_name.clone())
560 .with_arg("from", at(scope.from_ms))
561 .with_arg("to", at(scope.to_ms))
562 .with_arg("steps", scope.steps)
563}
564
565fn reviewed_fields(
567 cx: &ViewCx<FeedbackDraft>,
568 fields: &LocalState<Vec<crate::feedback::ContextField>>,
569) -> Element {
570 cx.switch(fields, |part, fields| {
571 let mut controls = group();
572 for field in fields {
573 controls = controls.push(Box::new(
574 Text::new("feedback-reviewed-text").with_arg("value", field.value.clone()),
575 ));
576 controls = controls.push(Box::new(part.effect_button(
577 "feedback-hide-context",
578 &FeedbackEffect::Redact {
579 key: field.key.clone(),
580 start: 0,
581 end: field.value.len(),
582 },
583 )));
584 }
585 Box::new(controls)
586 })
587}
588
589fn group() -> Column {
592 Column::new().with_intrinsic_height().with_no_padding()
593}
594
595fn install_effects(cx: &ViewCx<FeedbackDraft>, presentation: &Presentation) -> bool {
598 let services = cx
600 .is_private()
601 .then(|| cx.take_lent::<FeedbackServices>())
602 .flatten();
603 let lease = cx.take_lent::<PresentationLease>();
604 let mut screenshots = false;
605 if let Some(services) = &services {
606 *services.presentation.borrow_mut() = Some(presentation.clone());
607 if let Ok(composer) = services.controller.try_borrow() {
608 screenshots = composer.offers_screenshots();
609 presentation.show(&composer);
610 }
611 restore_progress(services, &presentation.status);
612 }
613 let shown = presentation.clone();
614 cx.effects(move |effect: FeedbackEffect, effect_cx| {
615 let _lease = &lease;
616 let Some(services) = &services else {
617 shown.status.set("feedback-unconfigured".to_owned());
618 return Err(said("feedback-unconfigured"));
619 };
620 let draft = effect_cx.live().with(Clone::clone);
621 let mut composer = services
622 .controller
623 .try_borrow_mut()
624 .map_err(|_| said("feedback-busy"))?;
625 let consent = Consent {
629 context: draft.include_context && composer.subject().is_some(),
630 screenshot: draft.include_screenshot,
631 recording: draft.include_recording && composer.recording_scope().is_some(),
632 };
633 let picked = composer.picked().map(|draft| draft.picks.len());
635 let words = picked.map(|picks| report::words(&draft, &shown, picks, consent.recording));
636 let result = match (effect, &words) {
637 (FeedbackEffect::Review, Some(words)) => {
638 let reviewed = composer.review_report(words);
639 if reviewed.is_ok() {
640 let (models, name) = services.models.borrow().clone();
641 *services.pass.borrow_mut() =
642 report::ModelPass::start(&models, &name, &mut composer, words).map(
643 |mut pass| {
644 Box::new(move |composer: &mut Composer| pass.poll(composer))
645 as Box<report::PassPoll>
646 },
647 );
648 }
649 reviewed
650 }
651 (FeedbackEffect::Review, None) => composer.review(&draft.issue, consent),
652 (FeedbackEffect::Submit, _) => {
653 let id = effect_cx
654 .invocation()
655 .ok_or_else(|| said("feedback-busy"))?
656 .0
657 .simple()
658 .to_string();
659 match &words {
660 Some(words) => composer.submit_report(id, words),
661 None => composer.submit(id, &draft.issue, consent),
662 }
663 }
664 (FeedbackEffect::Retry, _) => composer.pump(),
665 (FeedbackEffect::Redact { key, start, end }, _) => composer.hide(&key, start, end),
666 (FeedbackEffect::RemoveScreenshot, _) => composer.remove_screenshot(),
667 (FeedbackEffect::RemoveRecording, _) => composer.remove_recording(),
668 (FeedbackEffect::Toggle { index }, _) => composer.toggle_redaction(index),
669 };
670 shown.show(&composer);
671 let key = if let Err(error) = &result {
672 error_label(error)
673 } else {
674 match composer
675 .state()
676 .map_err(|_| said("feedback-retry-needed"))?
677 {
678 None => "feedback-reviewed",
679 Some(state) => delivery_label(&state),
680 }
681 };
682 shown.status.set(key.to_owned());
683 result.map_err(|error| {
684 tracing::warn!(%error, "a feedback action did not run");
687 said(key)
688 })?;
689 Ok(Effected::done())
690 });
691 screenshots
692}
693
694fn said(key: &str) -> String {
696 ui_core::i18n::I18nManager::lookup(key, None)
697}
698
699fn error_label(error: &crate::feedback::Error) -> &'static str {
700 match error {
701 crate::feedback::Error::CaptureProtected => "feedback-capture-unavailable",
702 crate::feedback::Error::CaptureChanged => "feedback-capture-changed",
703 crate::feedback::Error::NotApproved => "feedback-review-first",
704 crate::feedback::Error::Store(_) => "feedback-storage-failed",
705 _ => "feedback-retry-needed",
706 }
707}
708
709fn restore_progress(services: &FeedbackServices, status: &LocalState<String>) {
710 match services.controller.try_borrow() {
711 Ok(composer) => match composer.state() {
712 Ok(Some(state)) => status.set(delivery_label(&state).to_owned()),
713 Ok(None) => {}
714 Err(_) => status.set("feedback-retry-needed".to_owned()),
715 },
716 Err(_) => status.set("feedback-busy".to_owned()),
717 }
718}
719
720fn delivery_label(state: &DeliveryState) -> &'static str {
721 match state {
722 DeliveryState::Saving => "feedback-saving",
723 DeliveryState::StorageFailed => "feedback-storage-failed",
724 DeliveryState::AwaitingReceipt => "feedback-awaiting-receipt",
725 DeliveryState::Sent { .. } => "feedback-sent",
726 }
727}
728
729#[cfg(test)]
730#[cfg(not(target_arch = "wasm32"))]
731mod tests {
732 use super::*;
733 use crate::feedback::{
734 Destination, Error, FeedbackHost, MaskedScreenshot, MemoryStore, Outbox, Receipt,
735 RecordingScope, ReviewText, StorageKey, Submission,
736 };
737 use crate::headless::FrameHarness;
738 use serde_json::json;
739
740 #[derive(Default)]
741 struct Transport {
742 submissions: Vec<Submission>,
743 receipt: Option<Receipt>,
744 }
745 struct Host(Rc<RefCell<Transport>>);
746 impl FeedbackHost for Host {
747 fn offers_screenshots(&self) -> bool {
748 false
749 }
750 fn context(&mut self) -> Result<Vec<ReviewText>, Error> {
751 Ok(vec![ReviewText {
752 node: "focused.public.label".into(),
753 sensitivity: crate::feedback::DeclaredSensitivity::Public,
754 kind: None,
755 origin: crate::feedback::TextOrigin::Semantic,
756 text: "feedback-privacy".into(),
757 }])
758 }
759 fn capture(&mut self) -> Result<MaskedScreenshot, Error> {
760 Err(Error::CaptureProtected)
761 }
762 fn persist_screenshot(&mut self, _: &MaskedScreenshot) -> Result<(), Error> {
763 Err(Error::CaptureProtected)
764 }
765 fn send(&mut self, submission: &Submission) -> Result<(), Error> {
766 self.0.borrow_mut().submissions.push(submission.clone());
767 Ok(())
768 }
769 fn receipt(&mut self, _: &Submission) -> Result<Option<Receipt>, Error> {
770 Ok(self.0.borrow().receipt.clone())
771 }
772 }
773
774 fn edit(edit: &tilo_resource::Edit<FeedbackDraft>) -> serde_json::Value {
776 json!({ tilo_resource::EDIT_PARAM: edit })
777 }
778
779 fn describe(frame: &mut FrameHarness, issue: &str) -> Result<(), tilo_actions::DoorError> {
781 call(
782 frame,
783 tilo_resource::FIELD_EDIT,
784 &edit(&FeedbackDraft::paths().issue.replace(issue)),
785 )
786 }
787
788 fn call(
789 frame: &mut FrameHarness,
790 action: &str,
791 arguments: &serde_json::Value,
792 ) -> Result<(), tilo_actions::DoorError> {
793 frame.relayout();
794 let resource = frame
795 .resources()
796 .into_iter()
797 .find(|resource| resource.kind == FeedbackDraft::KIND)
798 .expect("the real composer resource is published");
799 frame
800 .invoke_bound_action(
801 &crate::bound_dispatch::BoundActionAddress {
802 instance_id: 42,
803 address: tilo_actions::ActionAddress::new(resource.id.0, action),
804 },
805 arguments.as_object().expect("typed arguments"),
806 )
807 .map(|_| ())
808 }
809
810 #[test]
811 fn private_storage_wake_sends_only_after_host_acknowledges_approval() {
812 let transport = Rc::new(RefCell::new(Transport::default()));
813 let storage = Rc::new(MemoryStore::queued());
814 let controller = Composer::new(
815 Destination {
816 room: "private-fixture".into(),
817 provider: "record:chat:feedback".into(),
818 channel: "feedback".into(),
819 },
820 Outbox::open(storage.clone(), StorageKey::package("feedback-outbox"))
821 .expect("private outbox"),
822 Box::new(Host(transport.clone())),
823 )
824 .expect("admitted destination");
825 let services = FeedbackServices::new(Rc::new(RefCell::new(controller)));
826 let component =
827 prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
828 let mut frame = FrameHarness::from_component(
829 component,
830 (720, 720),
831 ComponentRunnerInputs::from_native_process(),
832 )
833 .expect("production component");
834 call(
835 &mut frame,
836 tilo_resource::FIELD_EDIT,
837 &edit(
838 &FeedbackDraft::paths()
839 .issue
840 .replace("Opening the book failed"),
841 ),
842 )
843 .expect("describe");
844 call(&mut frame, "review", &json!({})).expect("review");
845 call(&mut frame, "submit", &json!({})).expect("queue approval");
846 frame.relayout();
847 assert!(
848 frame
849 .inspection_json()
850 .expect("inspection")
851 .contains("Saving on this device")
852 );
853 assert!(services.next_wake_deadline().is_some());
854 services.pump().expect("storage still pending");
855 assert!(transport.borrow().submissions.is_empty());
856 let pending = storage.drain();
857 assert_eq!(pending.len(), 1);
858 storage
859 .acknowledge(pending[0].revision, Ok(()))
860 .expect("host commit");
861 services.pump().expect("durable approval sends");
862 frame.relayout();
863 assert_eq!(transport.borrow().submissions.len(), 1);
864 let again = services.next_wake_deadline().expect("receipt wake");
866 let now = crate::time::now();
867 assert!(again >= now.checked_add(RECEIPT_CHECK_FIRST / 2).unwrap());
868 assert!(again <= now.checked_add(RECEIPT_CHECK_LAST).unwrap());
869 assert!(
870 frame
871 .inspection_json()
872 .expect("inspection")
873 .contains("Waiting for destination confirmation")
874 );
875 assert!(
876 !frame
877 .inspection_json()
878 .expect("inspection")
879 .contains("Feedback received")
880 );
881 let submission = transport.borrow().submissions[0].clone();
883 transport.borrow_mut().receipt = Some(Receipt {
884 id: submission.id.clone(),
885 digest: submission.digest().expect("digest"),
886 destination: submission.destination,
887 report: "chat.message:m_feedback_later".into(),
888 });
889 services.pump().expect("receipt wake");
890 for write in storage.drain() {
891 storage
892 .acknowledge(write.revision, Ok(()))
893 .expect("host commit");
894 }
895 services.pump().expect("receipt kept");
896 frame.relayout();
897 assert!(
898 frame
899 .inspection_json()
900 .expect("inspection")
901 .contains("Feedback received")
902 );
903 assert!(services.next_wake_deadline().is_none());
904 }
905
906 #[test]
907 fn semantic_composer_reviews_queues_and_waits_for_destination_receipt() {
908 let transport = Rc::new(RefCell::new(Transport::default()));
909 let controller = Rc::new(RefCell::new(
910 Composer::new(
911 Destination {
912 room: "demo".into(),
913 provider: "record:chat:feedback".into(),
914 channel: "feedback".into(),
915 },
916 Outbox::open(
917 Rc::new(MemoryStore::new()),
918 StorageKey::package("feedback-outbox"),
919 )
920 .expect("private outbox"),
921 Box::new(Host(transport.clone())),
922 )
923 .expect("admitted destination"),
924 ));
925 let services = FeedbackServices::new(controller.clone());
926 let component =
927 prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
928 assert!(
929 prepare(Some(services.clone()), "device", "guest", 43).is_err(),
930 "one approved outbox must not have competing private editors"
931 );
932 let mut frame = FrameHarness::from_component(
933 component,
934 (720, 720),
935 ComponentRunnerInputs::from_native_process(),
936 )
937 .expect("production component");
938 assert!(transport.borrow().submissions.is_empty());
939 describe(&mut frame, "Opening the book failed; token=secret12345")
940 .expect("describe through public action");
941 frame.relayout();
942 assert!(
943 frame.capture_protected(),
944 "the actual private composer paint must protect its enclosing frame"
945 );
946 assert!(
947 call(&mut frame, "submit", &json!({})).is_err(),
948 "sending before review must fail"
949 );
950 call(&mut frame, "review", &json!({})).expect("review");
951 call(&mut frame, "submit", &json!({})).expect("approve exact preview");
952 let submission = transport
953 .borrow()
954 .submissions
955 .first()
956 .expect("existing transport request")
957 .clone();
958 assert!(!submission.feedback.text.contains("secret12345"));
959 assert!(submission.feedback.context.is_empty());
960 assert!(submission.feedback.screenshot.is_none());
961 assert_eq!(
962 controller.borrow().state().expect("state"),
963 Some(DeliveryState::AwaitingReceipt)
964 );
965 call(&mut frame, "retry", &json!({})).expect("stable retry");
966 assert!(
967 transport
968 .borrow()
969 .submissions
970 .iter()
971 .all(|retry| retry == &submission)
972 );
973 transport.borrow_mut().receipt = Some(Receipt {
974 id: submission.id.clone(),
975 digest: submission.digest().expect("digest"),
976 destination: submission.destination,
977 report: "chat.message:m_feedback_test".into(),
978 });
979 services.pump().expect("host receipt wake");
980 frame.relayout();
981 assert!(
982 serde_json::to_string(&frame.inspection())
983 .expect("real component inspection")
984 .contains("Feedback received"),
985 "the private UI must show receipt progress without another click"
986 );
987 assert_eq!(
988 controller.borrow().state().expect("state"),
989 Some(DeliveryState::Sent {
990 report: "chat.message:m_feedback_test".into()
991 })
992 );
993 services
994 .about(Some(Subject::of_app("so.tilo.books", "Books")))
995 .expect("the desktop names the window feedback is about");
996 assert_literal_second_review(&mut frame, &transport);
997 frame.close_components();
998 assert!(
999 controller.borrow().preview().is_none(),
1000 "closing discards the unapproved review"
1001 );
1002 let reopened = prepare(Some(services), "device", "guest", 42)
1003 .expect("closed editor releases its private lease");
1004 drop(reopened);
1005 }
1006
1007 fn find(node: &crate::headless::InspectionNode) -> Option<&crate::headless::InspectionNode> {
1008 if node.test_id.as_deref() == Some("feedback-preview") {
1009 Some(node)
1010 } else {
1011 node.children.iter().find_map(find)
1012 }
1013 }
1014 fn contains_literal_context(node: &crate::headless::InspectionNode) -> bool {
1015 node.semantics
1016 .as_ref()
1017 .and_then(|node| node.name.as_deref())
1018 == Some("feedback-privacy")
1019 || node.children.iter().any(contains_literal_context)
1020 }
1021 fn assert_literal_second_review(frame: &mut FrameHarness, transport: &Rc<RefCell<Transport>>) {
1022 transport.borrow_mut().receipt = None;
1023 describe(frame, "feedback-title").expect("second issue");
1024 call(
1025 frame,
1026 tilo_resource::FIELD_EDIT,
1027 &edit(&FeedbackDraft::paths().include_context.set(true)),
1028 )
1029 .expect("explicit context opt-in");
1030 call(frame, "review", &json!({})).expect("new review after durable receipt");
1031 frame.relayout();
1032 let inspection = frame.inspection();
1033 let preview = find(&inspection.root).expect("displayed approved preview");
1034 assert_eq!(
1035 preview
1036 .semantics
1037 .as_ref()
1038 .and_then(|node| node.name.as_deref()),
1039 Some("feedback-title"),
1040 "user text matching a Fluent key must remain literal"
1041 );
1042 assert!(contains_literal_context(&inspection.root));
1043 call(frame, "submit", &json!({})).expect("second approved submission");
1044 let sent = transport.borrow();
1045 let first = sent.submissions.first().expect("first report");
1046 let second = sent.submissions.last().expect("second report");
1047 assert_ne!(first.id, second.id);
1048 assert_eq!(second.feedback.text, "feedback-title");
1049 let context: Vec<_> = second
1050 .feedback
1051 .context
1052 .iter()
1053 .map(|field| field.value.as_str())
1054 .collect();
1055 assert_eq!(context, ["App: Books (so.tilo.books)", "feedback-privacy"]);
1056 }
1057
1058 struct Recording {
1061 sent: Host,
1062 recorded: crate::feedback::Recorded,
1063 kept: Rc<RefCell<Vec<String>>>,
1064 }
1065 impl FeedbackHost for Recording {
1066 fn offers_screenshots(&self) -> bool {
1067 false
1068 }
1069 fn context(&mut self) -> Result<Vec<ReviewText>, Error> {
1070 Ok(Vec::new())
1071 }
1072 fn capture(&mut self) -> Result<MaskedScreenshot, Error> {
1073 Err(Error::CaptureProtected)
1074 }
1075 fn persist_screenshot(&mut self, _: &MaskedScreenshot) -> Result<(), Error> {
1076 Err(Error::CaptureProtected)
1077 }
1078 fn recording_scope(&mut self, subject: &Subject) -> Option<RecordingScope> {
1079 subject.record.as_ref().map(|_| self.recorded.scope())
1080 }
1081 fn record(&mut self, subject: &Subject) -> Result<crate::feedback::Recorded, Error> {
1082 subject.record.as_ref().ok_or(Error::Invalid("recording"))?;
1083 Ok(self.recorded.clone())
1084 }
1085 fn persist_recording(
1086 &mut self,
1087 recording: &crate::feedback::Recorded,
1088 ) -> Result<(), Error> {
1089 self.kept
1090 .borrow_mut()
1091 .push(recording.reference().hash.clone());
1092 Ok(())
1093 }
1094 fn send(&mut self, submission: &Submission) -> Result<(), Error> {
1095 self.sent.send(submission)
1096 }
1097 fn receipt(&mut self, submission: &Submission) -> Result<Option<Receipt>, Error> {
1098 self.sent.receipt(submission)
1099 }
1100 }
1101
1102 fn three_changes() -> crate::feedback::Recorded {
1104 use ui_core::automerge::{ROOT, transaction::Transactable as _};
1105 let mut doc = tilo_resource::model::Doc::new();
1106 for (second, body) in [(10, "a"), (20, "ab"), (30, "abc"), (40, "abcd")] {
1107 doc.put(ROOT, "body", body).expect("edit");
1108 tilo_resource::history::commit_stamped_at(&mut doc, second).expect("commit");
1109 }
1110 let heads = doc.get_heads();
1111 let cut = tilo_resource::recording::record(
1112 &doc,
1113 15_000,
1114 &heads,
1115 &crate::feedback::recording::LIMITS,
1116 )
1117 .expect("recording");
1118 crate::feedback::Recorded::new("so.tilo.notes", "Notes", "notes.note", &cut)
1119 .expect("recorded")
1120 }
1121
1122 fn shows_id(node: &crate::headless::InspectionNode, id: &str) -> bool {
1123 node.test_id.as_deref() == Some(id) || node.children.iter().any(|child| shows_id(child, id))
1124 }
1125
1126 #[test]
1127 fn a_recording_is_offered_for_a_window_with_a_record_and_sent_only_when_included() {
1128 let transport = Rc::new(RefCell::new(Transport::default()));
1129 let kept = Rc::new(RefCell::new(Vec::new()));
1130 let recorded = three_changes();
1131 let controller = Rc::new(RefCell::new(
1132 Composer::new(
1133 Destination {
1134 room: "demo".into(),
1135 provider: "record:chat:feedback".into(),
1136 channel: "feedback".into(),
1137 },
1138 Outbox::open(
1139 Rc::new(MemoryStore::new()),
1140 StorageKey::package("feedback-outbox"),
1141 )
1142 .expect("private outbox"),
1143 Box::new(Recording {
1144 sent: Host(transport.clone()),
1145 recorded: recorded.clone(),
1146 kept: kept.clone(),
1147 }),
1148 )
1149 .expect("admitted destination"),
1150 ));
1151 let services = FeedbackServices::new(controller);
1152 let component =
1153 prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
1154 let mut frame = FrameHarness::from_component(
1155 component,
1156 (720, 900),
1157 ComponentRunnerInputs::from_native_process(),
1158 )
1159 .expect("production component");
1160 services
1161 .about(Some(Subject::of_app("so.tilo.notes", "Notes")))
1162 .expect("a window that shows no record");
1163 frame.relayout();
1164 assert!(!shows_id(
1165 &frame.inspection().root,
1166 "feedback-recording-scope"
1167 ));
1168 services
1169 .about(Some(Subject {
1170 record: Some(crate::feedback::ShownRecord {
1171 workspace: "demo".into(),
1172 record: "note-1".into(),
1173 }),
1174 ..Subject::of_app("so.tilo.notes", "Notes")
1175 }))
1176 .expect("a window that shows a note");
1177 frame.relayout();
1178 assert!(
1179 shows_id(&frame.inspection().root, "feedback-recording-scope"),
1180 "the offer says what a recording holds before it is chosen"
1181 );
1182 describe(&mut frame, "Undo lost a word").expect("describe");
1183 call(&mut frame, "review", &json!({})).expect("review");
1184 frame.relayout();
1185 assert!(!shows_id(
1186 &frame.inspection().root,
1187 "feedback-recording-included"
1188 ));
1189 call(
1190 &mut frame,
1191 tilo_resource::FIELD_EDIT,
1192 &edit(&FeedbackDraft::paths().include_recording.set(true)),
1193 )
1194 .expect("include the recording");
1195 call(&mut frame, "review", &json!({})).expect("review with the recording");
1196 frame.relayout();
1197 assert!(
1198 shows_id(&frame.inspection().root, "feedback-recording-included"),
1199 "the review shows the recording that would be sent"
1200 );
1201 call(&mut frame, "submit", &json!({})).expect("send");
1202 let sent = transport
1203 .borrow()
1204 .submissions
1205 .last()
1206 .cloned()
1207 .expect("sent");
1208 assert_eq!(sent.feedback.recording.as_ref(), Some(recorded.reference()));
1209 assert_eq!(*kept.borrow(), vec![recorded.reference().hash.clone()]);
1210 }
1211
1212 fn picked_services(transport: &Rc<RefCell<Transport>>) -> FeedbackServices {
1214 use crate::feedback::report::{Area, Pick, PickedText, ReportDraft, TextKind};
1215 let controller = Rc::new(RefCell::new(
1216 Composer::new(
1217 Destination {
1218 room: "lounge".into(),
1219 provider: "record:so.tilo.chat:".into(),
1220 channel: "c_feedback".into(),
1221 },
1222 Outbox::open(
1223 Rc::new(MemoryStore::new()),
1224 StorageKey::package("feedback-outbox"),
1225 )
1226 .expect("private outbox"),
1227 Box::new(Host(Rc::clone(transport))),
1228 )
1229 .expect("admitted destination"),
1230 ));
1231 let services = FeedbackServices::new(controller);
1232 let pick = Pick {
1233 app: "so.tilo.chat".into(),
1234 app_name: "Chat".into(),
1235 build: Some("abc123".into()),
1236 path: "Chat[0]/Row[1]".into(),
1237 role: "ListItem".into(),
1238 kind: None,
1239 caption: "A message in Chat".into(),
1240 label: None,
1241 record: None,
1242 area: Area {
1243 x: 0.0,
1244 y: 0.0,
1245 width: 100.0,
1246 height: 20.0,
1247 },
1248 texts: vec![
1249 PickedText {
1250 kind: TextKind::Content,
1251 text: "Thanks Ada Lovelace".into(),
1252 area: None,
1253 },
1254 PickedText {
1255 kind: TextKind::Interface,
1256 text: "Reply".into(),
1257 area: None,
1258 },
1259 ],
1260 };
1261 services
1262 .begin_report(PickedReport {
1263 draft: ReportDraft {
1264 picks: vec![pick],
1265 names: vec!["Ada Lovelace".into()],
1266 ..ReportDraft::default()
1267 },
1268 crops: Vec::new(),
1269 })
1270 .expect("report begins");
1271 services
1272 }
1273
1274 #[test]
1275 fn a_picked_report_shows_exactly_what_is_sent_and_toggles_hidden_spans() {
1276 let transport = Rc::new(RefCell::new(Transport::default()));
1277 let services = picked_services(&transport);
1278 let component = prepare(Some(services), "device", "guest", 42).expect("private composer");
1279 let mut frame = FrameHarness::from_component(
1280 component,
1281 (720, 900),
1282 ComponentRunnerInputs::from_native_process(),
1283 )
1284 .expect("production component");
1285 frame.relayout();
1286 let sheet = frame.inspection_json().expect("inspection");
1287 assert!(sheet.contains("Report a problem"), "the report sheet shows");
1288 assert!(sheet.contains("1. A message in Chat"), "{sheet}");
1289 describe(&mut frame, "The row is cut off").expect("summary");
1290 call(&mut frame, "review", &json!({})).expect("review");
1291 frame.relayout();
1292 let preview = frame.inspection();
1293 let shown = find(&preview.root)
1294 .and_then(|node| node.semantics.as_ref())
1295 .and_then(|node| node.name.clone())
1296 .expect("the outgoing report is shown");
1297 assert!(shown.contains("The row is cut off"), "{shown}");
1298 assert!(shown.contains("Reply"), "{shown}");
1299 assert!(!shown.contains("Ada"), "content is hidden: {shown}");
1300 call(&mut frame, "toggle", &json!({"index": 0})).expect("show the message");
1301 frame.relayout();
1302 let preview = frame.inspection();
1303 let shown = find(&preview.root)
1304 .and_then(|node| node.semantics.as_ref())
1305 .and_then(|node| node.name.clone())
1306 .expect("the outgoing report is shown");
1307 assert!(shown.contains("Thanks \u{2039}name\u{203A}"), "{shown}");
1308 assert!(
1309 frame
1310 .inspection_json()
1311 .expect("inspection")
1312 .contains("Checked on this device by rules"),
1313 "with no model, the review says the rules checked it"
1314 );
1315 call(&mut frame, "submit", &json!({})).expect("send");
1316 let sent = transport.borrow();
1317 let submission = sent.submissions.first().expect("sent");
1318 assert_eq!(submission.feedback.selections.len(), 1);
1319 assert!(
1320 !serde_json::to_string(submission)
1321 .unwrap()
1322 .contains("Lovelace")
1323 );
1324 }
1325
1326 #[test]
1329 fn a_lent_model_reviews_the_model_view_and_the_review_names_it() {
1330 use crate::models::{
1331 CompletionFuture, Content, Message, ModelBackend, ModelRequest, ModelResponse, Role,
1332 StopReason, Usage,
1333 };
1334 struct Reviewer(Rc<RefCell<Vec<String>>>);
1335 impl ModelBackend for Reviewer {
1336 fn complete<'a>(&'a self, request: &'a ModelRequest) -> CompletionFuture<'a> {
1337 let asked: String = request
1338 .messages
1339 .iter()
1340 .flat_map(|message| &message.content)
1341 .filter_map(|content| match content {
1342 Content::Text { text } => Some(text.as_str()),
1343 _ => None,
1344 })
1345 .collect();
1346 let index = asked
1347 .lines()
1348 .find_map(|line| line.strip_suffix("] Reply")?.strip_prefix('['))
1349 .unwrap_or("0")
1350 .to_owned();
1351 self.0.borrow_mut().push(asked);
1352 Box::pin(async move {
1353 Ok(ModelResponse {
1354 message: Message::text(
1355 Role::Assistant,
1356 format!("{{\"hide\":[{{\"text\":{index},\"quote\":\"Reply\"}}]}}"),
1357 ),
1358 stop_reason: StopReason::EndTurn,
1359 usage: Usage::default(),
1360 cost_micros: 0,
1361 })
1362 })
1363 }
1364 }
1365 let transport = Rc::new(RefCell::new(Transport::default()));
1366 let services = picked_services(&transport);
1367 let asked = Rc::new(RefCell::new(Vec::new()));
1368 services.set_models(
1369 crate::models::Models::new(Rc::new(Reviewer(Rc::clone(&asked)))),
1370 "Anthropic claude-haiku-5-5",
1371 );
1372 let component =
1373 prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
1374 let mut frame = FrameHarness::from_component(
1375 component,
1376 (720, 900),
1377 ComponentRunnerInputs::from_native_process(),
1378 )
1379 .expect("production component");
1380 frame.relayout();
1381 describe(&mut frame, "Ada Lovelace says the row is cut off").expect("summary");
1382 call(&mut frame, "review", &json!({})).expect("review");
1383 services
1384 .pump()
1385 .expect("the model's answer is laid over the review");
1386 frame.relayout();
1387 let asked = asked.borrow();
1388 assert_eq!(asked.len(), 1, "one pass per review");
1389 assert!(
1390 !asked[0].contains("Lovelace") && !asked[0].contains("Thanks"),
1391 "the model sees placeholders for personal values: {}",
1392 asked[0]
1393 );
1394 let sheet = frame.inspection_json().expect("inspection");
1395 assert!(
1396 sheet.contains("Checked by rules and Anthropic claude-haiku-5-5, which hid more."),
1397 "{sheet}"
1398 );
1399 call(&mut frame, "submit", &json!({})).expect("send");
1400 let sent = transport.borrow();
1401 let submission = serde_json::to_string(sent.submissions.first().expect("sent")).unwrap();
1402 assert!(!submission.contains("Reply"), "the model's span is hidden");
1403 assert!(!submission.contains("Lovelace"), "the floor stands");
1404 }
1405}