Skip to main content

tilo_app/
feedback_composer.rs

1//! Shared private feedback presentation. Desktop hosts lend the system-owned
2//! controller into a private component; no draft is a workspace record.
3
4use 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/// Supplied by the desktop host, never resolved from a URL or draft content.
17#[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    /// Private outbox writes need a wake independently of the ephemeral draft.
26    /// Transport retries remain explicit; only pending local persistence polls.
27    #[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    /// Pump after a host storage/transport wake, publishing progress to the
48    /// current private presentation. Its `LocalState` retains the component's
49    /// retirement scope, so a late completion cannot wake a closed component.
50    ///
51    /// # Errors
52    /// Reports a busy controller or the actual storage/transport failure.
53    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
72/// The host retains the outbox, while exactly one private presentation owns
73/// its raw review. Retirement drops this lease with the actual effect owner.
74struct 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/// Ephemeral CRDT state, admitted only into a private component host. Its
87/// fields are edited at their paths; the effects review and send it.
88#[derive(Clone, Debug, Default, PartialEq, Model)]
89#[model(check = FeedbackDraft::valid)]
90pub struct FeedbackDraft {
91    /// The issue, in the person's words.
92    pub issue: String,
93    /// Whether contextual information is included.
94    pub include_context: bool,
95    /// Whether a single masked app screenshot is included.
96    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    /// Inspect exactly what will leave this device.
115    Review,
116    /// Approve the displayed review and durably queue it.
117    Submit,
118    /// Retry the immutable report and read its durable delivery status.
119    Retry,
120    /// Hide a span in the already-masked review.
121    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    /// Remove the screenshot from the review.
130    RemoveScreenshot,
131}
132
133/// Mounted inside desktop chrome, never as an overlapping application window.
134pub struct FeedbackComposer;
135
136/// The shared composer definition, instantiated by the SDK once.
137/// Hosts use this entry point to avoid another copy of its generic resource,
138/// history and effect machinery in each host crate.
139#[must_use]
140#[inline(never)]
141pub fn definition() -> &'static crate::application::ApplicationDefinition {
142    crate::framework::definition::<FeedbackComposer>()
143}
144
145/// Prepare the chrome component over an isolated, ephemeral store. The host's
146/// durable outbox is lent separately; raw drafts cannot reach profile records,
147/// history or room synchronization through this preparation path.
148///
149/// # Errors
150/// Reports invalid host identity or component admission failure.
151pub 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    // Refuse a host accidentally mounting the raw draft in a shared room.
292    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    /// The arguments of the field edit `edit`, as the door takes them.
439    fn edit(edit: &tilo_resource::Edit<FeedbackDraft>) -> serde_json::Value {
440        json!({ tilo_resource::EDIT_PARAM: edit })
441    }
442
443    /// Type the issue, as the composer's text field does.
444    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}