1use std::{
5 cell::{Cell, RefCell},
6 rc::Rc,
7};
8
9use crate::feedback::{Composer, Consent, DeliveryState};
10use crate::i18n::EmbeddedCatalog;
11use crate::prelude::*;
12
13static CATALOG: EmbeddedCatalog =
14 EmbeddedCatalog::new("en-US", &[("en-US", include_str!("feedback_composer.ftl"))]);
15
16#[derive(Clone)]
18pub struct FeedbackServices {
19 controller: Rc<RefCell<Composer>>,
20 presentation: Rc<RefCell<Option<LocalState<String>>>>,
21 mounted: Rc<Cell<bool>>,
22}
23
24impl FeedbackServices {
25 #[must_use]
28 pub fn next_wake_deadline(&self) -> Option<crate::time::Instant> {
29 let controller = self.controller.try_borrow().ok()?;
30 match controller.state().ok()? {
31 Some(DeliveryState::Saving) => {
32 crate::time::now().checked_add(crate::time::Duration::from_millis(100))
33 }
34 _ => None,
35 }
36 }
37
38 #[must_use]
39 pub fn new(controller: Rc<RefCell<Composer>>) -> Self {
40 Self {
41 controller,
42 presentation: Rc::default(),
43 mounted: Rc::default(),
44 }
45 }
46
47 pub fn pump(&self) -> Result<(), crate::feedback::Error> {
54 let result = self
55 .controller
56 .try_borrow_mut()
57 .map_err(|_| crate::feedback::Error::Invalid("composer busy"))
58 .and_then(|mut controller| controller.pump());
59 if let Ok(presentation) = self.presentation.try_borrow()
60 && let Some(status) = presentation.as_ref()
61 {
62 if let Err(error) = &result {
63 status.set(error_label(error).to_owned());
64 } else {
65 restore_progress(self, status);
66 }
67 }
68 result
69 }
70}
71
72struct PresentationLease(FeedbackServices);
75
76impl Drop for PresentationLease {
77 fn drop(&mut self) {
78 self.0.presentation.borrow_mut().take();
79 self.0.mounted.set(false);
80 if let Ok(mut controller) = self.0.controller.try_borrow_mut() {
81 controller.abandon_review();
82 }
83 }
84}
85
86#[derive(Clone, Debug, Default, PartialEq, Model)]
89#[model(check = FeedbackDraft::valid)]
90pub struct FeedbackDraft {
91 pub issue: String,
93 pub include_context: bool,
95 pub include_screenshot: bool,
97}
98
99impl FeedbackDraft {
100 fn valid(&self) -> Result<(), tilo_resource::Refusal> {
101 if self.issue.len() > 8_000 {
102 return Err(tilo_resource::Refusal::new("feedback-issue-too-long"));
103 }
104 Ok(())
105 }
106}
107
108#[resource(kind = "tilo.feedback-draft", label = "Feedback")]
109impl FeedbackDraft {}
110
111#[derive(Clone, Debug, Effect)]
112#[effect(operation = "feedback.compose", device_only)]
113pub enum FeedbackEffect {
114 Review,
116 Submit,
118 Retry,
120 Redact {
122 #[param("Reviewed field key")]
123 key: String,
124 #[param("UTF-8 byte start")]
125 start: usize,
126 #[param("UTF-8 byte end")]
127 end: usize,
128 },
129 RemoveScreenshot,
131}
132
133pub struct FeedbackComposer;
135
136#[must_use]
140#[inline(never)]
141pub fn definition() -> &'static crate::application::ApplicationDefinition {
142 crate::framework::definition::<FeedbackComposer>()
143}
144
145pub fn prepare(
152 services: Option<FeedbackServices>,
153 device: &str,
154 author: &str,
155 instance: u64,
156) -> Result<AppComponent, crate::launch::LaunchError> {
157 let definition = definition();
158 let store = Rc::new(crate::feedback::MemoryStore::new());
159 let room = "feedback-private-draft";
160 let records = crate::record::RecordOwners::with_store(store.clone())
161 .open(FeedbackComposer::ID, room)
162 .map_err(|error| crate::launch::LaunchError::Operation(error.to_string()))?;
163 let views = Rc::new(
164 InstanceViewStore::with_store(store, device, room)
165 .map_err(|error| crate::launch::LaunchError::Operation(error.to_string()))?,
166 );
167 let target = LaunchTarget::New;
168 let permissions = Rc::new(RefCell::new(crate::permissions::PermissionContext::member(
169 author,
170 crate::permissions::Role::Owner,
171 )));
172 let mut context = HostContext::prepare(
173 definition,
174 HostContextInputs {
175 records,
176 views,
177 device_id: device.to_owned(),
178 instance_id: instance,
179 permissions,
180 author: crate::permissions::UserId(author.to_owned()),
181 action_context: tilo_actions::ActionContext::default(),
182 },
183 &target,
184 )?;
185 context.provide_private();
186 if let Some(services) = services {
187 if services.mounted.replace(true) {
188 return Err(crate::launch::LaunchError::Operation(
189 "feedback-busy".into(),
190 ));
191 }
192 context.lend(PresentationLease(services.clone()));
193 context.lend(services);
194 }
195 definition.instantiate(context, target, RestoreInput::None)
196}
197
198impl ResourceApp for FeedbackComposer {
199 type Resource = FeedbackDraft;
200 const ID: &'static str = "so.tilo.feedback-composer";
201 const TITLE: &'static str = "Feedback";
202 const DOCUMENT_HISTORY: bool = false;
203 const PRIVATE_WINDOWS: bool = true;
204 const BEHAVIOUR: ComponentBehaviour = ComponentBehaviour {
205 capture_protected: true,
206 ..ComponentBehaviour::DEFAULT
207 };
208
209 fn catalog() -> Option<&'static EmbeddedCatalog> {
210 Some(&CATALOG)
211 }
212
213 fn view(cx: &mut ViewCx<FeedbackDraft>) -> Element {
214 let shown = LocalState::new(String::new());
215 let fields = LocalState::new(Vec::<crate::feedback::ContextField>::new());
216 let pixels = LocalState::new(None::<Vec<u8>>);
217 let status = LocalState::new("feedback-review-first".to_owned());
218 install_effects(cx, &shown, &status, &fields, &pixels);
219 let fields_view = cx.switch(&fields, |part, fields| {
220 let mut controls = Column::new();
221 for field in fields {
222 controls = controls.push(Box::new(
223 Text::new("feedback-reviewed-text").with_arg("value", field.value.clone()),
224 ));
225 controls = controls.push(Box::new(part.effect_button(
226 "feedback-hide-context",
227 &FeedbackEffect::Redact {
228 key: field.key.clone(),
229 start: 0,
230 end: field.value.len(),
231 },
232 )));
233 }
234 Box::new(controls)
235 });
236 let preview = cx.switch(&shown, |_, shown| {
237 Box::new(
238 Text::new("feedback-reviewed-text")
239 .with_arg("value", shown.clone())
240 .with_id("feedback-preview"),
241 )
242 });
243 let picture = cx.switch(&pixels, |_, png| {
244 png.as_ref()
245 .and_then(|bytes| crate::widget::Image::from_encoded(bytes).ok())
246 .map_or_else(
247 || Box::new(Column::new()) as Element,
248 |image| Box::new(image.with_alt("feedback-screenshot-preview")) as Element,
249 )
250 });
251 Box::new(
252 crate::column![
253 Text::new("feedback-title"),
254 cx.input(FeedbackDraft::paths().issue)
255 .with_id("feedback-issue")
256 .with_placeholder("feedback-describe")
257 .multiline(true),
258 cx.checkbox("feedback-context", FeedbackDraft::paths().include_context),
259 cx.checkbox(
260 "feedback-screenshot",
261 FeedbackDraft::paths().include_screenshot
262 ),
263 Text::new("feedback-privacy"),
264 cx.effect_button("feedback-review", &FeedbackEffect::Review),
265 ]
266 .push(preview)
267 .push(fields_view)
268 .push(picture)
269 .push(Box::new(crate::column![
270 cx.effect_button(
271 "feedback-remove-screenshot",
272 &FeedbackEffect::RemoveScreenshot
273 ),
274 cx.effect_button("feedback-submit", &FeedbackEffect::Submit),
275 Text::from_state(&status, Clone::clone)
276 .with_id("feedback-status")
277 .with_live(crate::a11y::Politeness::Polite),
278 cx.effect_button("feedback-retry", &FeedbackEffect::Retry),
279 ])),
280 )
281 }
282}
283
284fn install_effects(
285 cx: &ViewCx<FeedbackDraft>,
286 shown: &LocalState<String>,
287 status: &LocalState<String>,
288 fields: &LocalState<Vec<crate::feedback::ContextField>>,
289 pixels: &LocalState<Option<Vec<u8>>>,
290) {
291 let services = cx
293 .is_private()
294 .then(|| cx.take_lent::<FeedbackServices>())
295 .flatten();
296 let presentation = cx.take_lent::<PresentationLease>();
297 if let Some(services) = &services {
298 *services.presentation.borrow_mut() = Some(status.clone());
299 restore_progress(services, status);
300 }
301 let preview = shown.clone();
302 let reviewed_fields = fields.clone();
303 let reviewed_pixels = pixels.clone();
304 let progress = status.clone();
305 cx.effects(move |effect: FeedbackEffect, effect_cx| {
306 let _presentation = &presentation;
307 let Some(services) = &services else {
308 progress.set("feedback-unconfigured".to_owned());
309 return Err("feedback-unconfigured".to_owned());
310 };
311 let draft = effect_cx.live().with(Clone::clone);
312 let consent = Consent {
313 context: draft.include_context,
314 screenshot: draft.include_screenshot,
315 };
316 let mut composer = services
317 .controller
318 .try_borrow_mut()
319 .map_err(|_| "feedback-busy".to_owned())?;
320 let result = match effect {
321 FeedbackEffect::Review => composer.review(&draft.issue, consent),
322 FeedbackEffect::Submit => {
323 let id = effect_cx
324 .invocation()
325 .ok_or_else(|| "feedback-busy".to_owned())?
326 .0
327 .simple()
328 .to_string();
329 composer.submit(id, &draft.issue, consent)
330 }
331 FeedbackEffect::Retry => composer.pump(),
332 FeedbackEffect::Redact { key, start, end } => composer.hide(&key, start, end),
333 FeedbackEffect::RemoveScreenshot => composer.remove_screenshot(),
334 };
335 if let Some(review) = composer.preview() {
336 let feedback = review.feedback();
337 preview.set(feedback.text.clone());
338 reviewed_fields.set(feedback.context.clone());
339 reviewed_pixels.set(review.screenshot().map(|image| image.png().to_vec()));
340 } else {
341 preview.set(String::new());
342 reviewed_fields.set(Vec::new());
343 reviewed_pixels.set(None);
344 }
345 let key = if let Err(error) = &result {
346 error_label(error)
347 } else {
348 match composer
349 .state()
350 .map_err(|_| "feedback-retry-needed".to_owned())?
351 {
352 None => "feedback-reviewed",
353 Some(state) => delivery_label(&state),
354 }
355 };
356 progress.set(key.to_owned());
357 result.map_err(|_| key.to_owned())?;
358 Ok(Effected::done())
359 });
360}
361
362fn error_label(error: &crate::feedback::Error) -> &'static str {
363 match error {
364 crate::feedback::Error::CaptureProtected => "feedback-capture-unavailable",
365 crate::feedback::Error::CaptureChanged => "feedback-capture-changed",
366 crate::feedback::Error::NotApproved => "feedback-review-first",
367 crate::feedback::Error::Store(_) => "feedback-storage-failed",
368 _ => "feedback-retry-needed",
369 }
370}
371
372fn restore_progress(services: &FeedbackServices, status: &LocalState<String>) {
373 match services.controller.try_borrow() {
374 Ok(composer) => match composer.state() {
375 Ok(Some(state)) => status.set(delivery_label(&state).to_owned()),
376 Ok(None) => {}
377 Err(_) => status.set("feedback-retry-needed".to_owned()),
378 },
379 Err(_) => status.set("feedback-busy".to_owned()),
380 }
381}
382
383fn delivery_label(state: &DeliveryState) -> &'static str {
384 match state {
385 DeliveryState::Saving => "feedback-saving",
386 DeliveryState::StorageFailed => "feedback-storage-failed",
387 DeliveryState::AwaitingReceipt => "feedback-awaiting-receipt",
388 DeliveryState::Sent { .. } => "feedback-sent",
389 }
390}
391
392#[cfg(test)]
393#[cfg(not(target_arch = "wasm32"))]
394#[allow(
395 clippy::expect_used,
396 reason = "assertions prove the production component journey"
397)]
398mod tests {
399 use super::*;
400 use crate::feedback::{
401 Destination, Error, FeedbackHost, MaskedScreenshot, MemoryStore, Outbox, Receipt,
402 ReviewText, StorageKey, Submission,
403 };
404 use crate::headless::FrameHarness;
405 use serde_json::json;
406
407 #[derive(Default)]
408 struct Transport {
409 submissions: Vec<Submission>,
410 receipt: Option<Receipt>,
411 }
412 struct Host(Rc<RefCell<Transport>>);
413 impl FeedbackHost for Host {
414 fn context(&mut self) -> Result<Vec<ReviewText>, Error> {
415 Ok(vec![ReviewText {
416 node: "focused.public.label".into(),
417 sensitivity: crate::feedback::DeclaredSensitivity::Public,
418 kind: None,
419 origin: crate::feedback::TextOrigin::Semantic,
420 text: "feedback-privacy".into(),
421 }])
422 }
423 fn capture(&mut self) -> Result<MaskedScreenshot, Error> {
424 Err(Error::CaptureProtected)
425 }
426 fn persist_screenshot(&mut self, _: &MaskedScreenshot) -> Result<(), Error> {
427 Err(Error::CaptureProtected)
428 }
429 fn send(&mut self, submission: &Submission) -> Result<(), Error> {
430 self.0.borrow_mut().submissions.push(submission.clone());
431 Ok(())
432 }
433 fn receipt(&mut self, _: &Submission) -> Result<Option<Receipt>, Error> {
434 Ok(self.0.borrow().receipt.clone())
435 }
436 }
437
438 fn edit(edit: &tilo_resource::Edit<FeedbackDraft>) -> serde_json::Value {
440 json!({ tilo_resource::EDIT_PARAM: edit })
441 }
442
443 fn describe(frame: &mut FrameHarness, issue: &str) -> Result<(), tilo_actions::DoorError> {
445 call(
446 frame,
447 tilo_resource::FIELD_EDIT,
448 &edit(&FeedbackDraft::paths().issue.replace(issue)),
449 )
450 }
451
452 fn call(
453 frame: &mut FrameHarness,
454 action: &str,
455 arguments: &serde_json::Value,
456 ) -> Result<(), tilo_actions::DoorError> {
457 frame.relayout();
458 let resource = frame
459 .resources()
460 .into_iter()
461 .find(|resource| resource.kind == FeedbackDraft::KIND)
462 .expect("the real composer resource is published");
463 frame
464 .invoke_bound_action(
465 &crate::bound_dispatch::BoundActionAddress {
466 instance_id: 42,
467 address: tilo_actions::ActionAddress::new(resource.id.0, action),
468 },
469 arguments.as_object().expect("typed arguments"),
470 )
471 .map(|_| ())
472 }
473
474 #[test]
475 fn private_storage_wake_sends_only_after_host_acknowledges_approval() {
476 let transport = Rc::new(RefCell::new(Transport::default()));
477 let storage = Rc::new(MemoryStore::queued());
478 let controller = Composer::new(
479 Destination {
480 room: "private-fixture".into(),
481 provider: "record:chat:feedback".into(),
482 channel: "feedback".into(),
483 },
484 Outbox::open(storage.clone(), StorageKey::package("feedback-outbox"))
485 .expect("private outbox"),
486 Box::new(Host(transport.clone())),
487 )
488 .expect("admitted destination");
489 let services = FeedbackServices::new(Rc::new(RefCell::new(controller)));
490 let component =
491 prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
492 let mut frame = FrameHarness::from_component(
493 component,
494 (720, 720),
495 ComponentRunnerInputs::from_native_process(),
496 )
497 .expect("production component");
498 call(
499 &mut frame,
500 tilo_resource::FIELD_EDIT,
501 &edit(
502 &FeedbackDraft::paths()
503 .issue
504 .replace("Opening the book failed"),
505 ),
506 )
507 .expect("describe");
508 call(&mut frame, "review", &json!({})).expect("review");
509 call(&mut frame, "submit", &json!({})).expect("queue approval");
510 frame.relayout();
511 assert!(
512 frame
513 .inspection_json()
514 .expect("inspection")
515 .contains("Saving on this device")
516 );
517 assert!(services.next_wake_deadline().is_some());
518 services.pump().expect("storage still pending");
519 assert!(transport.borrow().submissions.is_empty());
520 let pending = storage.drain();
521 assert_eq!(pending.len(), 1);
522 storage
523 .acknowledge(pending[0].revision, Ok(()))
524 .expect("host commit");
525 services.pump().expect("durable approval sends");
526 frame.relayout();
527 assert_eq!(transport.borrow().submissions.len(), 1);
528 assert!(services.next_wake_deadline().is_none());
529 assert!(
530 frame
531 .inspection_json()
532 .expect("inspection")
533 .contains("Waiting for destination confirmation")
534 );
535 assert!(
536 !frame
537 .inspection_json()
538 .expect("inspection")
539 .contains("Feedback received")
540 );
541 }
542
543 #[test]
544 fn semantic_composer_reviews_queues_and_waits_for_destination_receipt() {
545 let transport = Rc::new(RefCell::new(Transport::default()));
546 let controller = Rc::new(RefCell::new(
547 Composer::new(
548 Destination {
549 room: "demo".into(),
550 provider: "record:chat:feedback".into(),
551 channel: "feedback".into(),
552 },
553 Outbox::open(
554 Rc::new(MemoryStore::new()),
555 StorageKey::package("feedback-outbox"),
556 )
557 .expect("private outbox"),
558 Box::new(Host(transport.clone())),
559 )
560 .expect("admitted destination"),
561 ));
562 let services = FeedbackServices::new(controller.clone());
563 let component =
564 prepare(Some(services.clone()), "device", "guest", 42).expect("private composer");
565 assert!(
566 prepare(Some(services.clone()), "device", "guest", 43).is_err(),
567 "one approved outbox must not have competing private editors"
568 );
569 let mut frame = FrameHarness::from_component(
570 component,
571 (720, 720),
572 ComponentRunnerInputs::from_native_process(),
573 )
574 .expect("production component");
575 assert!(transport.borrow().submissions.is_empty());
576 describe(&mut frame, "Opening the book failed; token=secret12345")
577 .expect("describe through public action");
578 frame.relayout();
579 assert!(
580 frame.capture_protected(),
581 "the actual private composer paint must protect its enclosing frame"
582 );
583 assert!(
584 call(&mut frame, "submit", &json!({})).is_err(),
585 "sending before review must fail"
586 );
587 call(&mut frame, "review", &json!({})).expect("review");
588 call(&mut frame, "submit", &json!({})).expect("approve exact preview");
589 let submission = transport
590 .borrow()
591 .submissions
592 .first()
593 .expect("existing transport request")
594 .clone();
595 assert!(!submission.feedback.text.contains("secret12345"));
596 assert!(submission.feedback.context.is_empty());
597 assert!(submission.feedback.screenshot.is_none());
598 assert_eq!(
599 controller.borrow().state().expect("state"),
600 Some(DeliveryState::AwaitingReceipt)
601 );
602 call(&mut frame, "retry", &json!({})).expect("stable retry");
603 assert!(
604 transport
605 .borrow()
606 .submissions
607 .iter()
608 .all(|retry| retry == &submission)
609 );
610 transport.borrow_mut().receipt = Some(Receipt {
611 id: submission.id.clone(),
612 digest: submission.digest().expect("digest"),
613 destination: submission.destination,
614 report: "chat.message:m_feedback_test".into(),
615 });
616 services.pump().expect("host receipt wake");
617 frame.relayout();
618 assert!(
619 serde_json::to_string(&frame.inspection())
620 .expect("real component inspection")
621 .contains("Feedback received"),
622 "the private UI must show receipt progress without another click"
623 );
624 assert_eq!(
625 controller.borrow().state().expect("state"),
626 Some(DeliveryState::Sent {
627 report: "chat.message:m_feedback_test".into()
628 })
629 );
630 assert_literal_second_review(&mut frame, &transport);
631 frame.close_components();
632 assert!(
633 controller.borrow().preview().is_none(),
634 "closing discards the unapproved review"
635 );
636 let reopened = prepare(Some(services), "device", "guest", 42)
637 .expect("closed editor releases its private lease");
638 drop(reopened);
639 }
640
641 fn find(node: &crate::headless::InspectionNode) -> Option<&crate::headless::InspectionNode> {
642 if node.test_id.as_deref() == Some("feedback-preview") {
643 Some(node)
644 } else {
645 node.children.iter().find_map(find)
646 }
647 }
648 fn contains_literal_context(node: &crate::headless::InspectionNode) -> bool {
649 node.semantics
650 .as_ref()
651 .and_then(|node| node.name.as_deref())
652 == Some("feedback-privacy")
653 || node.children.iter().any(contains_literal_context)
654 }
655 fn assert_literal_second_review(frame: &mut FrameHarness, transport: &Rc<RefCell<Transport>>) {
656 transport.borrow_mut().receipt = None;
657 describe(frame, "feedback-title").expect("second issue");
658 call(
659 frame,
660 tilo_resource::FIELD_EDIT,
661 &edit(&FeedbackDraft::paths().include_context.set(true)),
662 )
663 .expect("explicit context opt-in");
664 call(frame, "review", &json!({})).expect("new review after durable receipt");
665 frame.relayout();
666 let inspection = frame.inspection();
667 let preview = find(&inspection.root).expect("displayed approved preview");
668 assert_eq!(
669 preview
670 .semantics
671 .as_ref()
672 .and_then(|node| node.name.as_deref()),
673 Some("feedback-title"),
674 "user text matching a Fluent key must remain literal"
675 );
676 assert!(contains_literal_context(&inspection.root));
677 call(frame, "submit", &json!({})).expect("second approved submission");
678 let sent = transport.borrow();
679 let first = sent.submissions.first().expect("first report");
680 let second = sent.submissions.last().expect("second report");
681 assert_ne!(first.id, second.id);
682 assert_eq!(second.feedback.text, "feedback-title");
683 assert_eq!(
684 second
685 .feedback
686 .context
687 .first()
688 .expect("approved context")
689 .value,
690 "feedback-privacy"
691 );
692 }
693}