atproto blogging
1//! Action buttons for notebooks (pin/unpin, delete, settings).
2
3use crate::auth::AuthState;
4use crate::components::button::{Button, ButtonVariant};
5use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
6use crate::components::notebook::NotebookSettingsPanel;
7use crate::fetch::Fetcher;
8use dioxus::prelude::*;
9use jacquard::types::aturi::AtUri;
10use jacquard::types::ident::AtIdentifier;
11use jacquard::types::string::Cid;
12use jacquard::IntoStatic;
13use weaver_api::com_atproto::repo::put_record::PutRecord;
14use weaver_api::com_atproto::repo::strong_ref::StrongRef;
15use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile;
16use weaver_api::sh_weaver::notebook::book::Book;
17
18/// Action buttons for a notebook: pin/unpin, delete, settings.
19#[component]
20pub fn NotebookActions(
21 notebook_uri: AtUri<'static>,
22 notebook_cid: Cid<'static>,
23 notebook_title: String,
24 /// The Book record for populating settings form.
25 notebook: Book<'static>,
26 #[props(default = false)] is_pinned: bool,
27 #[props(default)] on_deleted: Option<EventHandler<()>>,
28 #[props(default)] on_pinned_changed: Option<EventHandler<bool>>,
29 #[props(default)] on_settings_saved: Option<EventHandler<()>>,
30) -> Element {
31 let auth_state = use_context::<Signal<AuthState>>();
32 let fetcher = use_context::<Fetcher>();
33
34 let mut show_delete_confirm = use_signal(|| false);
35 let mut show_settings = use_signal(|| false);
36 let mut show_dropdown = use_signal(|| false);
37 let mut deleting = use_signal(|| false);
38 let mut pinning = use_signal(|| false);
39 let mut error = use_signal(|| None::<String>);
40
41 // Check ownership - compare auth DID with notebook's authority
42 let current_did = auth_state.read().did.clone();
43 let notebook_authority = notebook_uri.authority();
44 let is_owner = match (¤t_did, notebook_authority) {
45 (Some(current), AtIdentifier::Did(notebook_did)) => *current == *notebook_did,
46 _ => false,
47 };
48
49 if !is_owner {
50 return rsx! {};
51 }
52
53 let notebook_uri_for_delete = notebook_uri.clone();
54 let title_for_display = notebook_title.clone();
55 let on_deleted_handler = on_deleted.clone();
56
57 let delete_fetcher = fetcher.clone();
58 let handle_delete = move |_| {
59 let fetcher = delete_fetcher.clone();
60 let uri = notebook_uri_for_delete.clone();
61 let on_deleted = on_deleted_handler.clone();
62
63 spawn(async move {
64 use jacquard::client::AgentSessionExt;
65
66 deleting.set(true);
67 error.set(None);
68
69 let rkey = match uri.rkey() {
70 Some(r) => r.clone().into_static(),
71 None => {
72 error.set(Some("Invalid notebook URI".to_string()));
73 deleting.set(false);
74 return;
75 }
76 };
77
78 let client = fetcher.get_client();
79 match client.delete_record::<Book>(rkey).await {
80 Ok(_) => {
81 show_delete_confirm.set(false);
82 if let Some(handler) = &on_deleted {
83 handler.call(());
84 }
85 }
86 Err(e) => {
87 error.set(Some(format!("Delete failed: {:?}", e)));
88 }
89 }
90 deleting.set(false);
91 });
92 };
93
94 // Handler for pinning/unpinning
95 let notebook_uri_for_pin = notebook_uri.clone();
96 let notebook_cid_for_pin = notebook_cid.clone();
97 let is_currently_pinned = is_pinned;
98 let on_pinned_changed_handler = on_pinned_changed.clone();
99 let pin_fetcher = fetcher.clone();
100 let handle_pin_toggle = move |_| {
101 let fetcher = pin_fetcher.clone();
102 let notebook_uri = notebook_uri_for_pin.clone();
103 let notebook_cid = notebook_cid_for_pin.clone();
104 let on_pinned_changed = on_pinned_changed_handler.clone();
105
106 spawn(async move {
107 use jacquard::{from_data, prelude::*, to_data, types::string::Nsid};
108 use weaver_api::app_bsky::actor::profile::Profile as BskyProfile;
109
110 pinning.set(true);
111 error.set(None);
112
113 let client = fetcher.get_client();
114
115 let did = match fetcher.current_did().await {
116 Some(d) => d,
117 None => {
118 error.set(Some("Not authenticated".to_string()));
119 pinning.set(false);
120 return;
121 }
122 };
123
124 let profile_uri_str = format!("at://{}/sh.weaver.actor.profile/self", did);
125
126 // Try to fetch existing weaver profile
127 let weaver_uri = match WeaverProfile::uri(&profile_uri_str) {
128 Ok(u) => u,
129 Err(_) => {
130 error.set(Some("Invalid profile URI".to_string()));
131 pinning.set(false);
132 return;
133 }
134 };
135 let existing_profile: Option<WeaverProfile<'static>> =
136 match client.fetch_record(&weaver_uri).await {
137 Ok(output) => Some(output.value),
138 Err(_) => None,
139 };
140
141 // Build the new pinned list
142 let new_pinned: Vec<StrongRef<'static>> = if is_currently_pinned {
143 // Unpin: remove from list
144 existing_profile
145 .as_ref()
146 .and_then(|p| p.pinned.as_ref())
147 .map(|pins| {
148 pins.iter()
149 .filter(|r| r.uri.as_ref() != notebook_uri.as_ref())
150 .cloned()
151 .collect()
152 })
153 .unwrap_or_default()
154 } else {
155 // Pin: add to list
156 let new_ref = StrongRef::new()
157 .uri(notebook_uri.clone().into_static())
158 .cid(notebook_cid.clone())
159 .build();
160 let mut pins = existing_profile
161 .as_ref()
162 .and_then(|p| p.pinned.clone())
163 .unwrap_or_default();
164 // Don't add if already exists
165 if !pins.iter().any(|r| r.uri.as_ref() == notebook_uri.as_ref()) {
166 pins.push(new_ref);
167 }
168 pins
169 };
170
171 // Build the profile to save
172 let profile_to_save = if let Some(existing) = existing_profile {
173 // Update existing profile
174 WeaverProfile {
175 pinned: Some(new_pinned),
176 ..existing
177 }
178 } else {
179 // Create new profile from bsky data
180 let bsky_uri_str = format!("at://{}/app.bsky.actor.profile/self", did);
181 let bsky_profile: Option<BskyProfile<'static>> =
182 match BskyProfile::uri(&bsky_uri_str) {
183 Ok(bsky_uri) => match client.fetch_record(&bsky_uri).await {
184 Ok(output) => Some(output.value),
185 Err(_) => None,
186 },
187 Err(_) => None,
188 };
189
190 WeaverProfile::new()
191 .maybe_display_name(
192 bsky_profile
193 .as_ref()
194 .and_then(|p| p.display_name.clone()),
195 )
196 .maybe_description(
197 bsky_profile.as_ref().and_then(|p| p.description.clone()),
198 )
199 .maybe_avatar(bsky_profile.as_ref().and_then(|p| p.avatar.clone()))
200 .maybe_banner(bsky_profile.as_ref().and_then(|p| p.banner.clone()))
201 .bluesky(true)
202 .created_at(jacquard::types::string::Datetime::now())
203 .pinned(new_pinned)
204 .build()
205 };
206
207 // Serialize and save
208 let profile_data = match to_data(&profile_to_save) {
209 Ok(d) => d,
210 Err(e) => {
211 error.set(Some(format!("Failed to serialize profile: {:?}", e)));
212 pinning.set(false);
213 return;
214 }
215 };
216
217 let request = PutRecord::new()
218 .repo(AtIdentifier::Did(did))
219 .collection(Nsid::new_static("sh.weaver.actor.profile").unwrap())
220 .rkey(jacquard::types::string::Rkey::new("self").unwrap())
221 .record(profile_data)
222 .build();
223
224 match client.send(request).await {
225 Ok(_) => {
226 show_dropdown.set(false);
227 if let Some(handler) = &on_pinned_changed {
228 handler.call(!is_currently_pinned);
229 }
230 }
231 Err(e) => {
232 error.set(Some(format!("Failed to update profile: {:?}", e)));
233 }
234 }
235 pinning.set(false);
236 });
237 };
238
239 // Settings handlers.
240 let on_settings_saved_handler = on_settings_saved.clone();
241 let handle_settings_saved = move |_| {
242 show_settings.set(false);
243 if let Some(handler) = &on_settings_saved_handler {
244 handler.call(());
245 }
246 };
247
248 let handle_settings_cancel = move |_| {
249 show_settings.set(false);
250 };
251
252 let handle_open_settings = move |_| {
253 show_dropdown.set(false);
254 show_settings.set(true);
255 };
256
257 rsx! {
258 div { class: "notebook-actions",
259 // Dropdown for actions
260 div { class: "notebook-actions-dropdown",
261 Button {
262 variant: ButtonVariant::Ghost,
263 onclick: move |_| show_dropdown.toggle(),
264 "⋮"
265 }
266
267 if show_dropdown() {
268 div { class: "dropdown-menu",
269 // Pin/Unpin (first)
270 button {
271 class: "dropdown-item",
272 disabled: pinning(),
273 onclick: handle_pin_toggle,
274 if pinning() {
275 "Updating..."
276 } else if is_pinned {
277 "Unpin"
278 } else {
279 "Pin"
280 }
281 }
282 // Settings
283 button {
284 class: "dropdown-item",
285 onclick: handle_open_settings,
286 "Settings"
287 }
288 // Delete (danger style)
289 button {
290 class: "dropdown-item dropdown-item-danger",
291 onclick: move |_| {
292 show_dropdown.set(false);
293 show_delete_confirm.set(true);
294 },
295 "Delete"
296 }
297 }
298 }
299 }
300
301 // Delete confirmation dialog
302 DialogRoot {
303 open: show_delete_confirm(),
304 on_open_change: move |open: bool| show_delete_confirm.set(open),
305 DialogContent {
306 DialogTitle { "Delete Notebook?" }
307 DialogDescription {
308 "Delete \"{title_for_display}\"? The entries will remain but will no longer be part of this notebook."
309 }
310 if let Some(ref err) = error() {
311 div { class: "dialog-error", "{err}" }
312 }
313 div { class: "dialog-actions",
314 Button {
315 variant: ButtonVariant::Destructive,
316 onclick: handle_delete,
317 disabled: deleting(),
318 if deleting() { "Deleting..." } else { "Delete" }
319 }
320 Button {
321 variant: ButtonVariant::Ghost,
322 onclick: move |_| show_delete_confirm.set(false),
323 "Cancel"
324 }
325 }
326 }
327 }
328
329 // Settings dialog
330 DialogRoot {
331 open: show_settings(),
332 on_open_change: move |open: bool| show_settings.set(open),
333 DialogContent {
334 DialogTitle { "Notebook Settings" }
335 NotebookSettingsPanel {
336 notebook_uri: notebook_uri.clone(),
337 book: notebook.clone(),
338 on_saved: handle_settings_saved,
339 on_cancel: handle_settings_cancel,
340 }
341 }
342 }
343 }
344 }
345}