atproto blogging
1//! Action buttons for entries (edit, delete, remove from notebook, pin/unpin).
2
3use crate::components::{AppLink, AppLinkTarget, use_app_navigate};
4use crate::auth::AuthState;
5use crate::components::button::{Button, ButtonVariant};
6use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
7use crate::fetch::Fetcher;
8use dioxus::prelude::*;
9use jacquard::smol_str::SmolStr;
10use jacquard::types::aturi::AtUri;
11use jacquard::types::ident::AtIdentifier;
12use jacquard::types::string::Cid;
13use jacquard::IntoStatic;
14use weaver_api::com_atproto::repo::put_record::PutRecord;
15use weaver_api::com_atproto::repo::strong_ref::StrongRef;
16use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile;
17use weaver_api::sh_weaver::notebook::PermissionsState;
18
19const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css");
20
21#[derive(Props, Clone, PartialEq)]
22pub struct EntryActionsProps {
23 /// The AT-URI of the entry
24 pub entry_uri: AtUri<'static>,
25 /// The CID of the entry (for StrongRef when pinning)
26 pub entry_cid: Cid<'static>,
27 /// The entry title (for display in confirmation)
28 pub entry_title: String,
29 /// Whether this entry is in a notebook (enables "remove from notebook")
30 #[props(default = false)]
31 pub in_notebook: bool,
32 /// Notebook title (if in_notebook is true, used for edit route)
33 #[props(default)]
34 pub notebook_title: Option<SmolStr>,
35 /// Whether this entry is currently pinned
36 #[props(default = false)]
37 pub is_pinned: bool,
38 /// Permissions state for edit access checking (if available)
39 #[props(default)]
40 pub permissions: Option<PermissionsState<'static>>,
41 /// Callback when entry is removed from notebook (for optimistic UI update)
42 #[props(default)]
43 pub on_removed: Option<EventHandler<()>>,
44 /// Callback when pin state changes
45 #[props(default)]
46 pub on_pinned_changed: Option<EventHandler<bool>>,
47}
48
49/// Action buttons for an entry: edit, delete, optionally remove from notebook.
50#[component]
51pub fn EntryActions(props: EntryActionsProps) -> Element {
52 let auth_state = use_context::<Signal<AuthState>>();
53 let fetcher = use_context::<Fetcher>();
54
55 let mut show_delete_confirm = use_signal(|| false);
56 let mut show_remove_confirm = use_signal(|| false);
57 let mut show_dropdown = use_signal(|| false);
58 let mut deleting = use_signal(|| false);
59 let mut removing = use_signal(|| false);
60 let mut pinning = use_signal(|| false);
61 let mut error = use_signal(|| None::<String>);
62
63 // Check edit access - use permissions if available, fall back to ownership check
64 let current_did = auth_state.read().did.clone();
65 let can_edit = match ¤t_did {
66 Some(did) => {
67 if let Some(ref perms) = props.permissions {
68 // Use ACL-based permissions
69 perms.editors.iter().any(|grant| grant.did == *did)
70 } else {
71 // Fall back to ownership check
72 match props.entry_uri.authority() {
73 AtIdentifier::Did(entry_did) => *did == *entry_did,
74 _ => false,
75 }
76 }
77 }
78 None => false,
79 };
80
81 if !can_edit {
82 return rsx! {};
83 }
84
85 // Extract rkey from URI for edit route
86 let rkey = match props.entry_uri.rkey() {
87 Some(r) => r.0.to_string(),
88 None => return rsx! {}, // Can't edit without rkey
89 };
90
91 // Build edit link target based on whether entry is in a notebook
92 let ident = props.entry_uri.authority().clone();
93 let edit_target = if props.in_notebook {
94 if let Some(ref notebook) = props.notebook_title {
95 AppLinkTarget::EntryEdit {
96 ident: ident.clone().into_static(),
97 book_title: notebook.clone(),
98 rkey: rkey.clone().into(),
99 }
100 } else {
101 AppLinkTarget::StandaloneEntryEdit {
102 ident: ident.clone().into_static(),
103 rkey: rkey.clone().into(),
104 }
105 }
106 } else {
107 AppLinkTarget::StandaloneEntryEdit {
108 ident: ident.clone().into_static(),
109 rkey: rkey.clone().into(),
110 }
111 };
112
113 // Get navigation function for post-delete redirect
114 let navigate = use_app_navigate();
115
116 let entry_uri_for_delete = props.entry_uri.clone();
117 let entry_title = props.entry_title.clone();
118
119 let delete_fetcher = fetcher.clone();
120 let handle_delete = move |_| {
121 let fetcher = delete_fetcher.clone();
122 let uri = entry_uri_for_delete.clone();
123 let navigate = navigate.clone();
124
125 spawn(async move {
126 use jacquard::client::AgentSessionExt;
127 use weaver_api::sh_weaver::notebook::entry::Entry;
128
129 deleting.set(true);
130 error.set(None);
131
132 let rkey = match uri.rkey() {
133 Some(r) => r.clone().into_static(),
134 None => {
135 error.set(Some("Invalid entry URI".to_string()));
136 deleting.set(false);
137 return;
138 }
139 };
140
141 let did = match fetcher.current_did().await {
142 Some(d) => d,
143 None => {
144 error.set(Some("Not authenticated".to_string()));
145 deleting.set(false);
146 return;
147 }
148 };
149
150 let client = fetcher.get_client();
151 match client.delete_record::<Entry>(rkey).await {
152 Ok(_) => {
153 show_delete_confirm.set(false);
154 // Navigate to profile after delete.
155 navigate(AppLinkTarget::Profile {
156 ident: AtIdentifier::Did(did),
157 });
158 }
159 Err(e) => {
160 error.set(Some(format!("Delete failed: {:?}", e)));
161 }
162 }
163 deleting.set(false);
164 });
165 };
166
167 // Handler for removing entry from notebook (keeps entry, just removes from notebook's list)
168 let entry_uri_for_remove = props.entry_uri.clone();
169 let notebook_title_for_remove = props.notebook_title.clone();
170 let on_removed = props.on_removed.clone();
171 let remove_fetcher = fetcher.clone();
172 let handle_remove_from_notebook = move |_| {
173 let fetcher = remove_fetcher.clone();
174 let entry_uri = entry_uri_for_remove.clone();
175 let notebook_title = notebook_title_for_remove.clone();
176 let on_removed = on_removed.clone();
177
178 spawn(async move {
179 use jacquard::{from_data, to_data, prelude::*, types::string::Nsid};
180 use weaver_api::sh_weaver::notebook::book::Book;
181
182 let client = fetcher.get_client();
183
184 removing.set(true);
185 error.set(None);
186
187 let notebook_title = match notebook_title {
188 Some(t) => t,
189 None => {
190 error.set(Some("No notebook specified".to_string()));
191 removing.set(false);
192 return;
193 }
194 };
195
196 let did = match fetcher.current_did().await {
197 Some(d) => d,
198 None => {
199 error.set(Some("Not authenticated".to_string()));
200 removing.set(false);
201 return;
202 }
203 };
204
205 // Get the notebook by title
206 let ident = AtIdentifier::Did(did.clone());
207 let notebook_result = fetcher.get_notebook(ident.clone(), notebook_title.clone()).await;
208
209 let (notebook_view, _) = match notebook_result {
210 Ok(Some(data)) => data.as_ref().clone(),
211 Ok(None) => {
212 error.set(Some("Notebook not found".to_string()));
213 removing.set(false);
214 return;
215 }
216 Err(e) => {
217 error.set(Some(format!("Failed to get notebook: {:?}", e)));
218 removing.set(false);
219 return;
220 }
221 };
222
223 // Parse the book record to get the entry_list
224 let mut book: Book = match from_data(¬ebook_view.record) {
225 Ok(b) => b,
226 Err(e) => {
227 error.set(Some(format!("Failed to parse notebook: {:?}", e)));
228 removing.set(false);
229 return;
230 }
231 };
232
233 // Filter out the entry
234 let entry_uri_str = entry_uri.as_str();
235 let original_len = book.entry_list.len();
236 book.entry_list.retain(|ref_| ref_.uri.as_str() != entry_uri_str);
237
238 if book.entry_list.len() == original_len {
239 error.set(Some("Entry not found in notebook".to_string()));
240 removing.set(false);
241 return;
242 }
243
244 // Get the notebook's rkey from its URI
245 let notebook_rkey = match notebook_view.uri.rkey() {
246 Some(r) => r,
247 None => {
248 error.set(Some("Invalid notebook URI".to_string()));
249 removing.set(false);
250 return;
251 }
252 };
253
254 // Convert book to Data for the request
255 let book_data = match to_data(&book) {
256 Ok(d) => d,
257 Err(e) => {
258 error.set(Some(format!("Failed to serialize notebook: {:?}", e)));
259 removing.set(false);
260 return;
261 }
262 };
263
264 // Update the notebook record
265 let request = PutRecord::new()
266 .repo(AtIdentifier::Did(did))
267 .collection(Nsid::new_static("sh.weaver.notebook.book").unwrap())
268 .rkey(notebook_rkey.clone())
269 .record(book_data)
270 .build();
271
272 match client.send(request).await {
273 Ok(_) => {
274 show_remove_confirm.set(false);
275 // Notify parent to remove from local state
276 if let Some(handler) = &on_removed {
277 handler.call(());
278 }
279 }
280 Err(e) => {
281 error.set(Some(format!("Failed to update notebook: {:?}", e)));
282 }
283 }
284 removing.set(false);
285 });
286 };
287
288 // Handler for pinning/unpinning
289 let entry_uri_for_pin = props.entry_uri.clone();
290 let entry_cid_for_pin = props.entry_cid.clone();
291 let is_currently_pinned = props.is_pinned;
292 let on_pinned_changed = props.on_pinned_changed.clone();
293 let pin_fetcher = fetcher.clone();
294 let handle_pin_toggle = move |_| {
295 let fetcher = pin_fetcher.clone();
296 let entry_uri = entry_uri_for_pin.clone();
297 let entry_cid = entry_cid_for_pin.clone();
298 let on_pinned_changed = on_pinned_changed.clone();
299
300 spawn(async move {
301 use jacquard::{from_data, prelude::*, to_data, types::string::Nsid};
302 use weaver_api::app_bsky::actor::profile::Profile as BskyProfile;
303
304 pinning.set(true);
305 error.set(None);
306
307 let client = fetcher.get_client();
308
309 let did = match fetcher.current_did().await {
310 Some(d) => d,
311 None => {
312 error.set(Some("Not authenticated".to_string()));
313 pinning.set(false);
314 return;
315 }
316 };
317
318 let profile_uri_str = format!("at://{}/sh.weaver.actor.profile/self", did);
319
320 // Try to fetch existing weaver profile
321 let weaver_uri = match WeaverProfile::uri(&profile_uri_str) {
322 Ok(u) => u,
323 Err(_) => {
324 error.set(Some("Invalid profile URI".to_string()));
325 pinning.set(false);
326 return;
327 }
328 };
329 let existing_profile: Option<WeaverProfile<'static>> =
330 match client.fetch_record(&weaver_uri).await {
331 Ok(output) => Some(output.value),
332 Err(_) => None,
333 };
334
335 // Build the new pinned list
336 let new_pinned: Vec<StrongRef<'static>> = if is_currently_pinned {
337 // Unpin: remove from list
338 existing_profile
339 .as_ref()
340 .and_then(|p| p.pinned.as_ref())
341 .map(|pins| {
342 pins.iter()
343 .filter(|r| r.uri.as_ref() != entry_uri.as_ref())
344 .cloned()
345 .collect()
346 })
347 .unwrap_or_default()
348 } else {
349 // Pin: add to list
350 let new_ref = StrongRef::new()
351 .uri(entry_uri.clone().into_static())
352 .cid(entry_cid.clone())
353 .build();
354 let mut pins = existing_profile
355 .as_ref()
356 .and_then(|p| p.pinned.clone())
357 .unwrap_or_default();
358 // Don't add if already exists
359 if !pins.iter().any(|r| r.uri.as_ref() == entry_uri.as_ref()) {
360 pins.push(new_ref);
361 }
362 pins
363 };
364
365 // Build the profile to save
366 let profile_to_save = if let Some(existing) = existing_profile {
367 // Update existing profile
368 WeaverProfile {
369 pinned: Some(new_pinned),
370 ..existing
371 }
372 } else {
373 // Create new profile from bsky data
374 let bsky_uri_str = format!("at://{}/app.bsky.actor.profile/self", did);
375 let bsky_profile: Option<BskyProfile<'static>> =
376 match BskyProfile::uri(&bsky_uri_str) {
377 Ok(bsky_uri) => match client.fetch_record(&bsky_uri).await {
378 Ok(output) => Some(output.value),
379 Err(_) => None,
380 },
381 Err(_) => None,
382 };
383
384 WeaverProfile::new()
385 .maybe_display_name(
386 bsky_profile
387 .as_ref()
388 .and_then(|p| p.display_name.clone()),
389 )
390 .maybe_description(
391 bsky_profile.as_ref().and_then(|p| p.description.clone()),
392 )
393 .maybe_avatar(bsky_profile.as_ref().and_then(|p| p.avatar.clone()))
394 .maybe_banner(bsky_profile.as_ref().and_then(|p| p.banner.clone()))
395 .bluesky(true)
396 .created_at(jacquard::types::string::Datetime::now())
397 .pinned(new_pinned)
398 .build()
399 };
400
401 // Serialize and save
402 let profile_data = match to_data(&profile_to_save) {
403 Ok(d) => d,
404 Err(e) => {
405 error.set(Some(format!("Failed to serialize profile: {:?}", e)));
406 pinning.set(false);
407 return;
408 }
409 };
410
411 let request = PutRecord::new()
412 .repo(AtIdentifier::Did(did))
413 .collection(Nsid::new_static("sh.weaver.actor.profile").unwrap())
414 .rkey(jacquard::types::string::Rkey::new("self").unwrap())
415 .record(profile_data)
416 .build();
417
418 match client.send(request).await {
419 Ok(_) => {
420 show_dropdown.set(false);
421 if let Some(handler) = &on_pinned_changed {
422 handler.call(!is_currently_pinned);
423 }
424 }
425 Err(e) => {
426 error.set(Some(format!("Failed to update profile: {:?}", e)));
427 }
428 }
429 pinning.set(false);
430 });
431 };
432
433 rsx! {
434 document::Link { rel: "stylesheet", href: ENTRY_ACTIONS_CSS }
435
436 div { class: "entry-actions",
437 // Edit button (always visible for owner)
438 AppLink {
439 to: edit_target,
440 class: Some("entry-action-link".to_string()),
441 Button {
442 variant: ButtonVariant::Ghost,
443 "Edit"
444 }
445 }
446
447 // Dropdown for destructive actions
448 div { class: "entry-actions-dropdown",
449 Button {
450 variant: ButtonVariant::Ghost,
451 onclick: move |_| show_dropdown.toggle(),
452 "⋮"
453 }
454
455 if show_dropdown() {
456 div { class: "dropdown-menu",
457 // Pin/Unpin (first)
458 button {
459 class: "dropdown-item",
460 disabled: pinning(),
461 onclick: handle_pin_toggle,
462 if pinning() {
463 "Updating..."
464 } else if props.is_pinned {
465 "Unpin"
466 } else {
467 "Pin"
468 }
469 }
470 // Remove from notebook (if in notebook)
471 if props.in_notebook {
472 button {
473 class: "dropdown-item",
474 onclick: move |_| {
475 show_dropdown.set(false);
476 show_remove_confirm.set(true);
477 },
478 "Remove from notebook"
479 }
480 }
481 // Delete (last, danger style)
482 button {
483 class: "dropdown-item dropdown-item-danger",
484 onclick: move |_| {
485 show_dropdown.set(false);
486 show_delete_confirm.set(true);
487 },
488 "Delete"
489 }
490 }
491 }
492 }
493
494 // Delete confirmation dialog
495 DialogRoot {
496 open: show_delete_confirm(),
497 on_open_change: move |open: bool| show_delete_confirm.set(open),
498 DialogContent {
499 DialogTitle { "Delete Entry?" }
500 DialogDescription {
501 "Delete \"{entry_title}\"? This removes the published entry. You can restore from drafts if needed."
502 }
503 if let Some(ref err) = error() {
504 div { class: "dialog-error", "{err}" }
505 }
506 div { class: "dialog-actions",
507 Button {
508 variant: ButtonVariant::Destructive,
509 onclick: handle_delete,
510 disabled: deleting(),
511 if deleting() { "Deleting..." } else { "Delete" }
512 }
513 Button {
514 variant: ButtonVariant::Ghost,
515 onclick: move |_| show_delete_confirm.set(false),
516 "Cancel"
517 }
518 }
519 }
520 }
521
522 // Remove from notebook confirmation dialog
523 if props.in_notebook {
524 {
525 let entry_title_for_remove = entry_title.clone();
526 rsx! {
527 DialogRoot {
528 open: show_remove_confirm(),
529 on_open_change: move |open: bool| show_remove_confirm.set(open),
530 DialogContent {
531 DialogTitle { "Remove from Notebook?" }
532 DialogDescription {
533 "Remove \"{entry_title_for_remove}\" from this notebook? The entry will still exist but won't be part of this notebook."
534 }
535 if let Some(ref err) = error() {
536 div { class: "dialog-error", "{err}" }
537 }
538 div { class: "dialog-actions",
539 Button {
540 variant: ButtonVariant::Primary,
541 onclick: handle_remove_from_notebook,
542 disabled: removing(),
543 if removing() { "Removing..." } else { "Remove" }
544 }
545 Button {
546 variant: ButtonVariant::Ghost,
547 onclick: move |_| show_remove_confirm.set(false),
548 "Cancel"
549 }
550 }
551 }
552 }
553 }
554 }
555 }
556 }
557 }
558}