cranpose_services/
share_sheet.rs1use std::{cell::RefCell, rc::Rc};
12
13use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
14use cranpose_macros::composable;
15
16#[derive(thiserror::Error, Debug)]
17pub enum ShareError {
18 #[error("sharing is not supported on this platform")]
19 Unsupported,
20 #[error("failed to present the share sheet: {0}")]
21 Failed(String),
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct ShareContent {
27 pub file_name: String,
29 pub mime_type: String,
31 pub bytes: Vec<u8>,
33 pub text: Option<String>,
35}
36
37impl ShareContent {
38 pub fn file(
40 file_name: impl Into<String>,
41 mime_type: impl Into<String>,
42 bytes: Vec<u8>,
43 ) -> Self {
44 Self {
45 file_name: file_name.into(),
46 mime_type: mime_type.into(),
47 bytes,
48 text: None,
49 }
50 }
51
52 pub fn with_text(mut self, text: impl Into<String>) -> Self {
54 self.text = Some(text.into());
55 self
56 }
57}
58
59pub trait ShareSheet {
62 fn share(&self, content: ShareContent) -> Result<(), ShareError>;
65
66 fn is_supported(&self) -> bool;
69}
70
71pub type ShareSheetRef = Rc<dyn ShareSheet>;
72
73thread_local! {
74 static PLATFORM_SHARE_SHEET: RefCell<Option<ShareSheetRef>> = const { RefCell::new(None) };
75}
76
77pub fn set_platform_share_sheet(share_sheet: ShareSheetRef) {
80 PLATFORM_SHARE_SHEET.with(|cell| *cell.borrow_mut() = Some(share_sheet));
81}
82
83pub fn clear_platform_share_sheet() {
85 PLATFORM_SHARE_SHEET.with(|cell| *cell.borrow_mut() = None);
86}
87
88fn registered_platform_share_sheet() -> Option<ShareSheetRef> {
89 PLATFORM_SHARE_SHEET.with(|cell| cell.borrow().clone())
90}
91
92struct PlatformShareSheet;
93
94impl ShareSheet for PlatformShareSheet {
95 fn share(&self, content: ShareContent) -> Result<(), ShareError> {
96 match registered_platform_share_sheet() {
97 Some(handler) => handler.share(content),
98 None => Err(ShareError::Unsupported),
99 }
100 }
101
102 fn is_supported(&self) -> bool {
103 registered_platform_share_sheet().is_some_and(|handler| handler.is_supported())
104 }
105}
106
107pub fn default_share_sheet() -> ShareSheetRef {
108 Rc::new(PlatformShareSheet)
109}
110
111pub fn local_share_sheet() -> CompositionLocal<ShareSheetRef> {
112 thread_local! {
113 static LOCAL_SHARE_SHEET: RefCell<Option<CompositionLocal<ShareSheetRef>>> = const { RefCell::new(None) };
114 }
115
116 LOCAL_SHARE_SHEET.with(|cell| {
117 let mut local = cell.borrow_mut();
118 local
119 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_share_sheet, Rc::ptr_eq))
120 .clone()
121 })
122}
123
124#[allow(non_snake_case)]
125#[composable]
126pub fn ProvideShareSheet(content: impl FnOnce()) {
127 let share_sheet = cranpose_core::remember(default_share_sheet).with(|state| state.clone());
128 let local = local_share_sheet();
129
130 CompositionLocalProvider(vec![local.provides(share_sheet)], move || {
131 content();
132 });
133}
134
135#[cfg(test)]
136mod tests {
137 use std::cell::RefCell;
138
139 use super::*;
140
141 struct RecordingShareSheet {
142 shared: RefCell<Option<ShareContent>>,
143 supported: bool,
144 }
145
146 impl ShareSheet for RecordingShareSheet {
147 fn share(&self, content: ShareContent) -> Result<(), ShareError> {
148 *self.shared.borrow_mut() = Some(content);
149 Ok(())
150 }
151 fn is_supported(&self) -> bool {
152 self.supported
153 }
154 }
155
156 #[test]
157 fn default_share_sheet_is_unsupported_without_a_backend() {
158 clear_platform_share_sheet();
159 let sheet = default_share_sheet();
160 assert!(!sheet.is_supported());
161 let error = sheet
162 .share(ShareContent::file(
163 "a.pdf",
164 "application/pdf",
165 vec![1, 2, 3],
166 ))
167 .expect_err("no backend installed");
168 assert!(matches!(error, ShareError::Unsupported));
169 }
170
171 #[test]
172 fn registered_share_sheet_takes_precedence() {
173 let recorder = Rc::new(RecordingShareSheet {
174 shared: RefCell::new(None),
175 supported: true,
176 });
177 set_platform_share_sheet(recorder.clone());
178
179 let sheet = default_share_sheet();
180 assert!(sheet.is_supported());
181 sheet
182 .share(ShareContent::file("r.pdf", "application/pdf", vec![9]).with_text("hi"))
183 .expect("registered backend shares");
184
185 let shared = recorder.shared.borrow();
186 let shared = shared.as_ref().expect("content recorded");
187 assert_eq!(shared.file_name, "r.pdf");
188 assert_eq!(shared.text.as_deref(), Some("hi"));
189
190 clear_platform_share_sheet();
191 assert!(!default_share_sheet().is_supported());
192 }
193}