summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/post_editor.rs489
-rw-r--r--src/lib.rs413
-rw-r--r--src/main.rs4
3 files changed, 537 insertions, 369 deletions
diff --git a/src/components/post_editor.rs b/src/components/post_editor.rs
new file mode 100644
index 0000000..be17251
--- /dev/null
+++ b/src/components/post_editor.rs
@@ -0,0 +1,489 @@
+use crate::components;
+use adw::prelude::*;
+
+use glib::translate::IntoGlib;
+use gtk::GridLayoutChild;
+use relm4::{gtk, prelude::{ComponentController, Controller}, Component, ComponentParts, ComponentSender, RelmWidgetExt};
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)]
+#[enum_type(name = "MicropubVisibility")]
+pub enum Visibility {
+    #[default]
+    Public = 0,
+    Private = 1,
+}
+impl std::fmt::Display for Visibility {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            Self::Public => "public",
+            Self::Private => "private"
+        })
+    }
+}
+
+#[derive(Default, Debug, PartialEq, Eq)]
+pub struct Post {
+    pub name: Option<String>,
+    pub summary: Option<String>,
+    pub tags: Vec<String>,
+    pub content: String,
+    pub visibility: Visibility
+}
+
+impl From<Post> for microformats::types::Item {
+    fn from(post: Post) -> Self {
+        use microformats::types::{Item, Class, KnownClass, PropertyValue};
+        let mut mf2 = Item::new(vec![Class::Known(KnownClass::Entry)]);
+
+        if let Some(name) = post.name {
+            mf2.properties.insert(
+                "name".to_owned(), vec![PropertyValue::Plain(name)]
+            );
+        }
+
+        if let Some(summary) = post.summary {
+            mf2.properties.insert(
+                "summary".to_owned(),
+                vec![PropertyValue::Plain(summary)]
+            );
+        }
+
+        if !post.tags.is_empty() {
+            mf2.properties.insert(
+                "category".to_string(),
+                post.tags.into_iter().map(PropertyValue::Plain).collect()
+            );
+        }
+
+        mf2.properties.insert(
+            "visibility".to_string(),
+            vec![PropertyValue::Plain(post.visibility.to_string())]
+        );
+
+        mf2.properties.insert(
+            "content".to_string(),
+            vec![PropertyValue::Plain(post.content)]
+        );
+
+        mf2
+    }
+}
+
+#[tracker::track]
+#[derive(Debug)]
+pub(crate) struct PostEditor<E> {
+    #[no_eq] smart_summary_busy_guard: Option<gtk::gio::ApplicationBusyGuard>,
+    sending: bool,
+
+    #[do_not_track] name_buffer: gtk::EntryBuffer,
+    #[do_not_track] summary_buffer: gtk::EntryBuffer,
+    #[do_not_track] content_buffer: gtk::TextBuffer,
+    visibility: Visibility,
+
+    #[do_not_track] wide_layout: gtk::GridLayout,
+    #[do_not_track] narrow_layout: gtk::BoxLayout,
+
+    #[do_not_track] smart_summary: Controller<components::SmartSummaryButton>,
+    _err: std::marker::PhantomData<E>
+}
+
+impl<E> PostEditor<E> {
+    fn busy_changed(&self) -> bool {
+        self.changed(Self::sending() | Self::smart_summary_busy_guard())
+    }
+    fn busy(&self) -> bool {
+        self.sending || self.smart_summary_busy_guard.is_some()
+    }
+}
+
+#[derive(Debug)]
+#[allow(private_interfaces)]
+pub enum Input<E: std::error::Error + std::fmt::Debug + 'static> {
+    #[doc(hidden)] SmartSummary(components::smart_summary::Output),
+    #[doc(hidden)] VisibilitySelected(Visibility),
+    Submit,
+    SubmitDone(glib::Uri),
+    SubmitError(E)
+}
+
+#[relm4::component(pub)]
+impl<E: std::error::Error + std::fmt::Debug + 'static> Component for PostEditor<E> {
+    type Init = Option<Post>;
+    type Output = Option<Post>;
+    type Input = Input<E>;
+    type CommandOutput = ();
+
+    view! {
+        #[root]
+        #[name = "toast_overlay"]
+        adw::ToastOverlay {
+            #[name = "content_wrapper"]
+            adw::BreakpointBin {
+                set_width_request: 360,
+                set_height_request: 480,
+
+                #[name = "content"]
+                gtk::Box {
+                    set_orientation: gtk::Orientation::Vertical,
+                    set_spacing: 5,
+                    set_margin_all: 5,
+
+                    #[name = "name_label"]
+                    gtk::Label {
+                        set_markup: "Name",
+                        set_margin_vertical: 10,
+                        set_margin_horizontal: 10,
+                        set_halign: gtk::Align::Start,
+                        set_valign: gtk::Align::Start,
+                    },
+                    #[name = "name_field"]
+                    gtk::Entry {
+                        set_hexpand: true,
+                        set_buffer: &model.name_buffer,
+                        #[track = "model.changed(Self::sending())"]
+                        set_sensitive: !model.sending,
+                    },
+
+                    #[name = "summary_label"]
+                    gtk::Label {
+                        set_markup: "Summary",
+                        set_margin_vertical: 10,
+                        set_margin_horizontal: 10,
+                        set_halign: gtk::Align::Start,
+                        set_valign: gtk::Align::Start,
+                    },
+                    #[name = "summary_field"]
+                    gtk::Box {
+                        set_orientation: gtk::Orientation::Horizontal,
+                        add_css_class: "linked",
+
+                        gtk::Entry {
+                            set_hexpand: true,
+                            set_buffer: &model.summary_buffer,
+                            #[track = "model.busy_changed()"]
+                            set_sensitive: !model.busy(),
+                        },
+
+                        model.smart_summary.widget(),
+                    },
+
+                    #[name = "tag_label"]
+                    gtk::Label {
+                        set_markup: "Tags",
+                        set_margin_vertical: 10,
+                        set_margin_horizontal: 10,
+                        set_halign: gtk::Align::Start,
+                        set_valign: gtk::Align::Start,
+                    },
+                    #[name = "tag_holder"]
+                    // TODO: tag component (because of complex logic)
+                    gtk::Box {
+                        add_css_class: "frame",
+                        set_hexpand: true,
+                        set_orientation: gtk::Orientation::Horizontal,
+                        set_spacing: 5,
+                        set_height_request: 36,
+                    },
+
+
+                    #[name = "content_label"]
+                    gtk::Label {
+                        set_markup: "Content",
+                        set_halign: gtk::Align::Start,
+                        set_valign: gtk::Align::Start,
+                        set_margin_vertical: 10,
+                        set_margin_horizontal: 10,
+                    },
+
+                    #[name = "content_textarea"]
+                    gtk::ScrolledWindow {
+                        set_vexpand: true,
+
+                        gtk::TextView {
+                            set_buffer: Some(&model.content_buffer),
+                            set_hexpand: true,
+                            #[iterate]
+                            add_css_class: &["frame", "view"],
+
+                            set_monospace: true,
+                            set_wrap_mode: gtk::WrapMode::Word,
+                            set_vscroll_policy: gtk::ScrollablePolicy::Natural,
+
+                            set_left_margin: 8,
+                            set_right_margin: 8,
+                            set_top_margin: 8,
+                            set_bottom_margin: 8,
+
+                            #[track = "model.changed(Self::sending())"]
+                            set_sensitive: !model.sending
+                        }
+                    },
+
+                    #[name = "misc_prop_wrapper"]
+                    gtk::Box {
+                        set_hexpand: true,
+
+                        gtk::FlowBox {
+                            set_hexpand: false,
+                            set_orientation: gtk::Orientation::Horizontal,
+                            set_homogeneous: false,
+                            set_column_spacing: 0,
+                            set_min_children_per_line: 2,
+                            set_max_children_per_line: 6,
+                            set_selection_mode: gtk::SelectionMode::None,
+
+                            append = &gtk::Box {
+                                set_spacing: 5,
+
+                                #[name = "visibility_label"]
+                                gtk::Label {
+                                    set_markup: "Visibility",
+                                    set_halign: gtk::Align::Start,
+                                    set_valign: gtk::Align::Start,
+                                    set_margin_vertical: 10,
+                                    set_margin_horizontal: 10,
+                                },
+                                #[name = "visibility_selector"]
+                                gtk::DropDown {
+                                    set_model: Some(&visibility_model),
+                                    set_hexpand: false,
+
+                                    #[track = "model.changed(Self::sending())"]
+                                    set_sensitive: !model.sending,
+
+                                    connect_selected_item_notify[sender] => move |w| {
+                                        if let Some(obj) = w.selected_item() {
+                                            let v = obj.downcast::<adw::EnumListItem>()
+                                                .unwrap()
+                                                .value();
+                                            let v = glib::EnumClass::new::<Visibility>()
+                                                .to_value(v)
+                                                .unwrap()
+                                                .get()
+                                                .unwrap();
+                                            sender.input(Self::Input::VisibilitySelected(v));
+                                        }
+                                    },
+                                },
+                            },
+                        },
+                    },
+                },
+
+                add_breakpoint = adw::Breakpoint::new(
+                    adw::BreakpointCondition::new_length(
+                        adw::BreakpointConditionLengthType::MinWidth,
+                        512.0,
+                        adw::LengthUnit::Px
+                    )
+                ) {
+                    add_setter: (&content, "layout_manager", Some(&model.wide_layout.to_value())),
+                    add_setter: (&name_label, "halign", Some(&gtk::Align::End.to_value())),
+                    add_setter: (&summary_label, "halign", Some(&gtk::Align::End.to_value())),
+                    add_setter: (&tag_label, "halign", Some(&gtk::Align::End.to_value())),
+                    add_setter: (&content_label, "halign", Some(&gtk::Align::End.to_value())),
+                },
+
+            }
+        }
+    }
+
+    fn init(init: Self::Init, root: Self::Root, sender: ComponentSender<Self>) -> ComponentParts<Self> {
+        let mut model = Self {
+            smart_summary_busy_guard: None,
+            sending: false,
+
+            name_buffer: gtk::EntryBuffer::default(),
+            summary_buffer: gtk::EntryBuffer::default(),
+            content_buffer: gtk::TextBuffer::default(),
+            visibility: Visibility::Public,
+
+            wide_layout: gtk::GridLayout::new(),
+            narrow_layout: gtk::BoxLayout::new(gtk::Orientation::Vertical),
+
+            smart_summary: components::SmartSummaryButton::builder()
+                .launch(())
+                .forward(sender.input_sender(), Input::SmartSummary),
+
+            tracker: Default::default(),
+            _err: std::marker::PhantomData,
+        };
+
+        let visibility_model = adw::EnumListModel::new(Visibility::static_type());
+
+        let widgets = view_output!();
+
+        widgets.visibility_selector.set_expression(Some(
+            gtk::ClosureExpression::new::<String>(
+                [] as [gtk::Expression; 0],
+                glib::closure::RustClosure::new(|v| {
+                    let list_item = v[0].get::<adw::EnumListItem>().unwrap();
+                    Some(list_item.name().into())
+                })
+            )
+        ));
+
+        if let Some(post) = init {
+            if let Some(name) = post.name {
+                model.name_buffer.set_text(glib::GString::from(name));
+            }
+            if let Some(summary) = post.summary {
+                model.summary_buffer.set_text(glib::GString::from(summary));
+            }
+
+            // TODO: tags
+
+            model.content_buffer.set_text(&post.content);
+
+            widgets.visibility_selector.set_selected(
+                visibility_model.find_position(post.visibility.into_glib())
+            );
+            model.visibility = post.visibility;
+        }
+
+        let layout = &model.wide_layout;
+        widgets.content.set_layout_manager(Some(layout.clone()));
+        layout.set_column_homogeneous(false);
+        layout.set_row_spacing(10);
+
+        enum Row<'a> {
+            TwoColumn(&'a gtk::Label, &'a gtk::Widget),
+            Span(&'a gtk::Widget)
+        }
+
+        for (row, content) in [
+            Row::TwoColumn(&widgets.name_label, widgets.name_field.upcast_ref::<gtk::Widget>()),
+            Row::TwoColumn(&widgets.summary_label, widgets.summary_field.upcast_ref::<gtk::Widget>()),
+            Row::TwoColumn(&widgets.tag_label, widgets.tag_holder.upcast_ref::<gtk::Widget>()),
+            Row::TwoColumn(&widgets.content_label, widgets.content_textarea.upcast_ref::<gtk::Widget>()),
+            Row::Span(widgets.misc_prop_wrapper.upcast_ref::<gtk::Widget>()),
+        ].into_iter().enumerate() {
+            match content {
+                Row::TwoColumn(label, field) => {
+                    let label_layout = layout.layout_child(label)
+                        .downcast::<GridLayoutChild>()
+                        .unwrap();
+                    label_layout.set_row(row as i32);
+                    label_layout.set_column(0);
+
+                    let field_layout = layout.layout_child(field)
+                        .downcast::<GridLayoutChild>()
+                        .unwrap();
+                    field_layout.set_row(row as i32);
+                    field_layout.set_column(1);
+                },
+                Row::Span(widget) => {
+                    let widget_layout = layout.layout_child(widget)
+                        .downcast::<GridLayoutChild>()
+                        .unwrap();
+                    widget_layout.set_row(row as i32);
+                    widget_layout.set_column_span(2);
+                }
+            }
+        }
+
+        widgets.content.set_layout_manager(Some(model.narrow_layout.clone()));
+
+        ComponentParts { model, widgets }
+    }
+
+    fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: ComponentSender<Self>, root: &Self::Root) {
+        match msg {
+            Input::SmartSummary(components::SmartSummaryOutput::Start) => {
+                widgets.content_textarea.set_sensitive(false);
+                if self.content_buffer.char_count() == 0 {
+                    let _ = self.smart_summary.sender().send(
+                        components::SmartSummaryInput::Cancel
+                    );
+                } else {
+                    let text = self.content_buffer.text(
+                        &self.content_buffer.start_iter(),
+                        &self.content_buffer.end_iter(),
+                        false
+                    );
+
+                    self.set_smart_summary_busy_guard(
+                        Some(relm4::main_adw_application().mark_busy())
+                    );
+                    if self.smart_summary.sender().send(
+                        components::SmartSummaryInput::Text(text.into())
+                    ).is_ok() {
+                        self.summary_buffer.set_text("");
+                    }
+                }
+                widgets.content_textarea.set_sensitive(true);
+            },
+            Input::SmartSummary(components::SmartSummaryOutput::Chunk(text)) => {
+                self.summary_buffer.insert_text(self.summary_buffer.length(), text);
+            },
+            Input::SmartSummary(components::SmartSummaryOutput::Done) => {
+                self.set_smart_summary_busy_guard(None);
+            },
+            Input::SmartSummary(components::SmartSummaryOutput::Error(err)) => {
+                self.set_smart_summary_busy_guard(None);
+
+                let toast = adw::Toast::new(&format!("Smart Summary error: {}", err));
+                toast.set_timeout(0);
+                toast.set_priority(adw::ToastPriority::High);
+                root.add_toast(toast);
+            },
+            Input::VisibilitySelected(vis) => {
+                log::debug!("Changed visibility: {}", vis);
+                self.visibility = vis;
+            },
+            Input::Submit => {
+                self.sending = true;
+                let post = if self.content_buffer.char_count() > 0 {
+                    Some(Post {
+                        name: if self.name_buffer.length() > 0 {
+                            Some(self.name_buffer.text().into())
+                        } else { None },
+                        summary: if self.summary_buffer.length() > 0 {
+                            Some(self.summary_buffer.text().into())
+                        } else { None },
+                        tags: vec![],
+                        content: self.content_buffer.text(
+                            &self.content_buffer.start_iter(),
+                            &self.content_buffer.end_iter(),
+                            false
+                        ).into(),
+                        visibility: self.visibility,
+                    })
+                } else { None };
+                let _ = sender.output(post);
+            },
+            Input::SubmitDone(location) => {
+                self.name_buffer.set_text("");
+                self.summary_buffer.set_text("");
+                // TODO: tags
+                self.content_buffer.set_text("");
+                let toast = adw::Toast::new("Post submitted");
+                toast.set_button_label(Some("Open"));
+                toast.connect_button_clicked(move |toast| {
+                    gtk::UriLauncher::new(&location.to_string()).launch(
+                        None::<&adw::ApplicationWindow>,
+                        None::<&gio::Cancellable>,
+                        glib::clone!(#[weak] toast, move |result| {
+                            if let Err(err) = result {
+                                log::warn!("Error opening post URI: {}", err);
+                            } else {
+                                toast.dismiss()
+                            }
+                        })
+                    );
+                });
+
+                root.add_toast(toast);
+            },
+            Input::SubmitError(err) => {
+                let toast = adw::Toast::new(&format!("Error sending post: {}", err));
+                toast.set_timeout(0);
+                toast.set_priority(adw::ToastPriority::High);
+
+                root.add_toast(toast);
+            }
+        }
+
+        self.update_view(widgets, sender);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 87eed10..a9335bc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,16 +1,22 @@
 use std::sync::Arc;
 
 use adw::prelude::*;
-use gtk::GridLayoutChild;
-use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt};
+use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentParts, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt};
 
 pub mod components {
     pub(crate) mod smart_summary;
     pub(crate) use smart_summary::{
         SmartSummaryButton, Output as SmartSummaryOutput, Input as SmartSummaryInput
     };
+
+    pub(crate) mod post_editor;
+    pub(crate) use post_editor::{
+        PostEditor, Input as PostEditorInput
+    };
 }
-mod widgets;
+
+use components::post_editor::{Post, Visibility};
+
 pub mod secrets;
 pub mod micropub;
 pub mod util;
@@ -18,56 +24,31 @@ pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl";
 
 pub const VISIBILITY: [&str; 2] = ["public", "private"];
 
-#[tracker::track]
 #[derive(Debug)]
-pub struct PostComposerModel {
-    /// Busy guard for generating the summary using an LLM.
-    /// Makes the summary field read-only and blocks the Smart Summary button.
-    #[no_eq] smart_summary_busy_guard: Option<gtk::gio::ApplicationBusyGuard>,
-    #[no_eq] submit_busy_guard: Option<gtk::gio::ApplicationBusyGuard>,
-
-    #[do_not_track] name_buffer: gtk::EntryBuffer,
-    #[do_not_track] summary_buffer: gtk::EntryBuffer,
-    #[do_not_track] content_buffer: gtk::TextBuffer,
-
-    #[do_not_track] wide_layout: gtk::GridLayout,
-    #[do_not_track] narrow_layout: gtk::BoxLayout,
-
-    #[do_not_track] micropub: Arc<micropub::Client>,
-
-    #[do_not_track] smart_summary: Controller<components::SmartSummaryButton>,
-}
+pub struct App {
+    micropub: Arc<micropub::Client>,
+    submit_busy_guard: Option<gtk::gio::ApplicationBusyGuard>,
 
-impl PostComposerModel {
-    fn busy_changed(&self) -> bool {
-        self.changed(Self::submit_busy_guard() | Self::smart_summary_busy_guard())
-    }
-    fn busy(&self) -> bool {
-        self.submit_busy_guard.is_some() || self.smart_summary_busy_guard.is_some()
-    }
-}
-
-#[derive(Debug)]
-#[allow(private_interfaces)]
-pub enum PostComposerInput {
-    #[doc(hidden)] SmartSummary(components::smart_summary::Output),
-    Submit,
+    post_editor: Controller<components::PostEditor<micropub::Error>>
 }
 
 #[derive(Debug)]
-pub enum PostComposerCommandOutput {
+#[doc(hidden)]
+pub enum Input {
+    SubmitButtonPressed,
+    PostEditor(Option<Post>)
 }
 
 #[relm4::component(pub async)]
-impl AsyncComponent for PostComposerModel {
+impl AsyncComponent for App {
     /// The type of the messages that this component can receive.
-    type Input = PostComposerInput;
+    type Input = Input;
     /// The type of the messages that this component can send.
     type Output = ();
     /// The type of data with which this component will be initialized.
     type Init = micropub::Client;
     /// The type of the command outputs that this component can receive.
-    type CommandOutput = PostComposerCommandOutput;
+    type CommandOutput = ();
  
     view! {
         #[root]
@@ -83,9 +64,9 @@ impl AsyncComponent for PostComposerModel {
                             set_icon_name: "document-send-symbolic",
                             set_tooltip: "Send post",
 
-                            connect_clicked => Self::Input::Submit,
-                            #[track = "model.busy_changed()"]
-                            set_sensitive: !model.busy(),
+                            connect_clicked => Self::Input::SubmitButtonPressed,
+                            #[watch]
+                            set_sensitive: model.submit_busy_guard.is_none(),
                         },
 
                         bar = adw::HeaderBar::new() {
@@ -95,163 +76,8 @@ impl AsyncComponent for PostComposerModel {
 
                     bar
                 },
-                #[name = "toast_overlay"]
-                adw::ToastOverlay {
-                    #[name = "content_wrapper"]
-                    adw::BreakpointBin {
-                        set_width_request: 360,
-                        set_height_request: 480,
-
-                        #[name = "content"]
-                        gtk::Box {
-                            set_orientation: gtk::Orientation::Vertical,
-                            set_spacing: 5,
-                            set_margin_all: 5,
-
-                            #[name = "name_label"]
-                            gtk::Label {
-                                set_markup: "Name",
-                                set_margin_vertical: 10,
-                                set_margin_horizontal: 10,
-                                set_halign: gtk::Align::Start,
-                                set_valign: gtk::Align::Start,
-                            },
-                            #[name = "name_field"]
-                            gtk::Entry {
-                                set_hexpand: true,
-                                set_buffer: &model.name_buffer,
-                                #[track = "model.changed(Self::submit_busy_guard())"]
-                                set_sensitive: model.submit_busy_guard.is_none(),
-                            },
-
-                            #[name = "summary_label"]
-                            gtk::Label {
-                                set_markup: "Summary",
-                                set_margin_vertical: 10,
-                                set_margin_horizontal: 10,
-                                set_halign: gtk::Align::Start,
-                                set_valign: gtk::Align::Start,
-                            },
-                            #[name = "summary_field"]
-                            gtk::Box {
-                                set_orientation: gtk::Orientation::Horizontal,
-                                add_css_class: "linked",
-
-                                gtk::Entry {
-                                    set_hexpand: true,
-                                    set_buffer: &model.summary_buffer,
-                                    #[track = "model.busy_changed()"]
-                                    set_sensitive: !model.busy(),
-                                },
 
-                                model.smart_summary.widget(),
-                            },
-
-                            #[name = "tag_label"]
-                            gtk::Label {
-                                set_markup: "Tags",
-                                set_margin_vertical: 10,
-                                set_margin_horizontal: 10,
-                                set_halign: gtk::Align::Start,
-                                set_valign: gtk::Align::Start,
-                            },
-                            #[name = "tag_holder"]
-                            // TODO: tag component (because of complex logic)
-                            gtk::Box {
-                                add_css_class: "frame",
-                                set_hexpand: true,
-                                set_orientation: gtk::Orientation::Horizontal,
-                                set_spacing: 5,
-                                set_height_request: 36,
-                            },
-
-
-                            #[name = "content_label"]
-                            gtk::Label {
-                                set_markup: "Content",
-                                set_halign: gtk::Align::Start,
-                                set_valign: gtk::Align::Start,
-                                set_margin_vertical: 10,
-                                set_margin_horizontal: 10,
-                            },
-
-                            #[name = "content_textarea"]
-                            gtk::ScrolledWindow {
-                                set_vexpand: true,
-
-                                gtk::TextView {
-                                    set_buffer: Some(&model.content_buffer),
-                                    set_hexpand: true,
-                                    #[iterate]
-                                    add_css_class: &["frame", "view"],
-
-                                    set_monospace: true,
-                                    set_wrap_mode: gtk::WrapMode::Word,
-                                    set_vscroll_policy: gtk::ScrollablePolicy::Natural,
-
-                                    set_left_margin: 8,
-                                    set_right_margin: 8,
-                                    set_top_margin: 8,
-                                    set_bottom_margin: 8,
-
-                                    #[track = "model.changed(Self::submit_busy_guard())"]
-                                    set_sensitive: model.submit_busy_guard.is_none(),
-                                }
-                            },
-
-                            #[name = "misc_prop_wrapper"]
-                            gtk::Box {
-                                set_hexpand: true,
-
-                                gtk::FlowBox {
-                                    set_hexpand: false,
-                                    set_orientation: gtk::Orientation::Horizontal,
-                                    set_homogeneous: false,
-                                    set_column_spacing: 0,
-                                    set_min_children_per_line: 2,
-                                    set_max_children_per_line: 6,
-                                    set_selection_mode: gtk::SelectionMode::None,
-
-                                    append = &gtk::Box {
-                                        set_spacing: 5,
-
-                                        #[name = "visibility_label"]
-                                        gtk::Label {
-                                            set_markup: "Visibility",
-                                            set_halign: gtk::Align::Start,
-                                            set_valign: gtk::Align::Start,
-                                            set_margin_vertical: 10,
-                                            set_margin_horizontal: 10,
-                                        },
-                                        #[name = "visibility_selector"]
-                                        gtk::DropDown {
-                                            set_model: Some(&gtk::StringList::new(&VISIBILITY)),
-                                            set_hexpand: false,
-
-                                            #[track = "model.changed(Self::submit_busy_guard())"]
-                                            set_sensitive: model.submit_busy_guard.is_none(),
-                                        },
-                                    },
-                                },
-                            },
-                        },
-
-                        add_breakpoint = adw::Breakpoint::new(
-                            adw::BreakpointCondition::new_length(
-                                adw::BreakpointConditionLengthType::MinWidth,
-                                512.0,
-                                adw::LengthUnit::Px
-                            )
-                        ) {
-                            add_setter: (&content, "layout_manager", Some(&model.wide_layout.to_value())),
-                            add_setter: (&name_label, "halign", Some(&gtk::Align::End.to_value())),
-                            add_setter: (&summary_label, "halign", Some(&gtk::Align::End.to_value())),
-                            add_setter: (&tag_label, "halign", Some(&gtk::Align::End.to_value())),
-                            add_setter: (&content_label, "halign", Some(&gtk::Align::End.to_value())),
-                        },
-
-                    }
-                }
+                model.post_editor.widget(),
             }
         }
                 
@@ -262,24 +88,12 @@ impl AsyncComponent for PostComposerModel {
         window: Self::Root,
         sender: AsyncComponentSender<Self>,
     ) -> AsyncComponentParts<Self> {
-        let content_buffer = gtk::TextBuffer::default();
-        let model = PostComposerModel {
-            smart_summary_busy_guard: None,
+        let model = App {
             submit_busy_guard: None,
-
-            name_buffer: gtk::EntryBuffer::default(),
-            summary_buffer: gtk::EntryBuffer::default(),
-            content_buffer: content_buffer.clone(),
-
-            wide_layout: gtk::GridLayout::new(),
-            narrow_layout: gtk::BoxLayout::new(gtk::Orientation::Vertical),
-
             micropub: Arc::new(init),
-            smart_summary: components::SmartSummaryButton::builder()
-                .launch(())
-                .forward(sender.input_sender(), PostComposerInput::SmartSummary),
-
-            tracker: Default::default()
+            post_editor: components::PostEditor::builder()
+                .launch(None)
+                .forward(sender.input_sender(), Self::Input::PostEditor),
         };
 
         let widgets = view_output!();
@@ -287,176 +101,41 @@ impl AsyncComponent for PostComposerModel {
         #[cfg(debug_assertions)]
         window.add_css_class("devel");
 
-        let layout = &model.wide_layout;
-        widgets.content.set_layout_manager(Some(layout.clone()));
-        layout.set_column_homogeneous(false);
-        layout.set_row_spacing(10);
-
-        enum Row<'a> {
-            TwoColumn(&'a gtk::Label, &'a gtk::Widget),
-            Span(&'a gtk::Widget)
-        }
-
-        for (row, content) in [
-            Row::TwoColumn(&widgets.name_label, widgets.name_field.upcast_ref::<gtk::Widget>()),
-            Row::TwoColumn(&widgets.summary_label, widgets.summary_field.upcast_ref::<gtk::Widget>()),
-            Row::TwoColumn(&widgets.tag_label, widgets.tag_holder.upcast_ref::<gtk::Widget>()),
-            Row::TwoColumn(&widgets.content_label, widgets.content_textarea.upcast_ref::<gtk::Widget>()),
-            Row::Span(widgets.misc_prop_wrapper.upcast_ref::<gtk::Widget>()),
-        ].into_iter().enumerate() {
-            match content {
-                Row::TwoColumn(label, field) => {
-                    let label_layout = layout.layout_child(label)
-                        .downcast::<GridLayoutChild>()
-                        .unwrap();
-                    label_layout.set_row(row as i32);
-                    label_layout.set_column(0);
-
-                    let field_layout = layout.layout_child(field)
-                        .downcast::<GridLayoutChild>()
-                        .unwrap();
-                    field_layout.set_row(row as i32);
-                    field_layout.set_column(1);
-                },
-                Row::Span(widget) => {
-                    let widget_layout = layout.layout_child(widget)
-                        .downcast::<GridLayoutChild>()
-                        .unwrap();
-                    widget_layout.set_row(row as i32);
-                    widget_layout.set_column_span(2);
-                }
-            }
-        }
-
-        widgets.content.set_layout_manager(Some(model.narrow_layout.clone()));
-
         AsyncComponentParts { model, widgets }
     }
 
-    async fn update_with_view(
+    async fn update(
         &mut self,
-        widgets: &mut Self::Widgets,
         message: Self::Input,
-        sender: AsyncComponentSender<Self>,
-        root: &Self::Root
+        _sender: AsyncComponentSender<Self>,
+        _root: &Self::Root
     ) {
-        self.reset(); // Reset the tracker
-
         match message {
-            PostComposerInput::SmartSummary(components::SmartSummaryOutput::Start) => {
-                widgets.content_textarea.set_sensitive(false);
-                if self.content_buffer.char_count() == 0 {
-                    let _ = self.smart_summary.sender().send(
-                        components::SmartSummaryInput::Cancel
-                    );
-                } else {
-                    let text = self.content_buffer.text(
-                        &self.content_buffer.start_iter(),
-                        &self.content_buffer.end_iter(),
-                        false
-                    );
-
-                    self.set_smart_summary_busy_guard(
-                        Some(relm4::main_adw_application().mark_busy())
-                    );
-                    if self.smart_summary.sender().send(
-                        components::SmartSummaryInput::Text(text.into())
-                    ).is_ok() {
-                        self.summary_buffer.set_text("");
-                    }
-                }
-                widgets.content_textarea.set_sensitive(true);
-            },
-            PostComposerInput::SmartSummary(components::SmartSummaryOutput::Chunk(text)) => {
-                self.summary_buffer.insert_text(self.summary_buffer.length(), text);
+            Input::SubmitButtonPressed => {
+                self.submit_busy_guard = Some(relm4::main_adw_application().mark_busy());
+                self.post_editor.sender().send(components::PostEditorInput::Submit).unwrap();
             },
-            PostComposerInput::SmartSummary(components::SmartSummaryOutput::Done) => {
-                self.set_smart_summary_busy_guard(None);
+            Input::PostEditor(None) => {
+                self.submit_busy_guard = None;
             }
-            PostComposerInput::SmartSummary(components::SmartSummaryOutput::Error(err)) => {
-                self.set_smart_summary_busy_guard(None);
-
-                let toast = adw::Toast::new(&format!("Smart Summary error: {}", err));
-                toast.set_timeout(0);
-                toast.set_priority(adw::ToastPriority::High);
-                widgets.toast_overlay.add_toast(toast);
-
-            },
-            PostComposerInput::Submit => {
-                if self.content_buffer.char_count() == 0 {
-                    self.update_view(widgets, sender);
-                    return
-                }
-                self.set_submit_busy_guard(
-                    Some(relm4::main_adw_application().mark_busy())
-                );
-                // Update view to lock the interface up
-                self.update_view(widgets, sender.clone());
-                self.reset();
-
-                use microformats::types::{Item, Class, KnownClass, PropertyValue};
-                let mut mf2 = Item::new(vec![Class::Known(KnownClass::Entry)]);
-                if self.name_buffer.length() > 0 {
-                    let proplist = mf2.properties.entry("name".to_owned()).or_default();
-                    proplist.push(PropertyValue::Plain(self.name_buffer.text().into()));
-                }
-                if self.summary_buffer.length() > 0 {
-                    let proplist = mf2.properties.entry("summary".to_owned()).or_default();
-                    proplist.push(PropertyValue::Plain(self.summary_buffer.text().into()));
-                }
-
-                // TODO: tags
-
-                {
-                    let proplist = mf2.properties.entry("content".to_owned()).or_default();
-                    proplist.push(PropertyValue::Plain(self.content_buffer.text(
-                        &self.content_buffer.start_iter(),
-                        &self.content_buffer.end_iter(),
-                        false
-                    ).into()));
-                }
-
-                {
-                    let proplist = mf2.properties.entry("visibility".to_owned()).or_default();
-                    let selected = VISIBILITY[widgets.visibility_selector.selected() as usize];
-                    proplist.push(PropertyValue::Plain(selected.to_owned()));
-                }
-
+            Input::PostEditor(Some(post)) => {
+                let mf2 = post.into();
+                log::debug!("Submitting post: {:#}", serde_json::to_string(&mf2).unwrap());
                 match self.micropub.send_post(mf2).await {
                     Ok(location) => {
-                        self.name_buffer.set_text("");
-                        self.summary_buffer.set_text("");
-                        // TODO: tags
-                        self.content_buffer.set_text("");
-                        let toast = adw::Toast::new("Post submitted");
-                        toast.set_button_label(Some("Open"));
-                        toast.connect_button_clicked(glib::clone!(#[strong] root, move |toast| {
-                            gtk::UriLauncher::new(&location.to_string()).launch(
-                                Some(root.upcast_ref::<gtk::ApplicationWindow>()),
-                                None::<&gio::Cancellable>,
-                                glib::clone!(#[weak] toast, move |result| {
-                                    if let Err(err) = result {
-                                        log::warn!("Error opening post URI: {}", err);
-                                    } else {
-                                        toast.dismiss()
-                                    }
-                                })
-                            );
-                        }));
-                        widgets.toast_overlay.add_toast(toast);
+                        self.post_editor.sender()
+                            .send(components::PostEditorInput::SubmitDone(location))
+                            .unwrap();
                     },
                     Err(err) => {
                         log::warn!("Error sending post: {}", err);
-                        let toast = adw::Toast::new(&format!("Error sending post: {}", err));
-                        toast.set_timeout(0);
-                        toast.set_priority(adw::ToastPriority::High);
-                        widgets.toast_overlay.add_toast(toast);
+                        self.post_editor.sender()
+                            .send(components::PostEditorInput::SubmitError(err))
+                            .unwrap();
                     }
                 }
-                self.set_submit_busy_guard(None);
+                self.submit_busy_guard = None;
             },
         }
-
-        self.update_view(widgets, sender);
     }
 }
diff --git a/src/main.rs b/src/main.rs
index acb9f66..31d7d24 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,7 +1,7 @@
 use adw::prelude::GtkWindowExt;
 use relm4::{ComponentParts, ComponentSender, RelmApp, Component, ComponentController};
 
-use bowl::PostComposerModel;
+use bowl::App;
 
 use bowl::APPLICATION_ID;
 
@@ -15,7 +15,7 @@ fn main() {
     log::set_max_level(log::LevelFilter::Debug);
 
     let app = RelmApp::new(APPLICATION_ID);
-    app.run_async::<PostComposerModel>(
+    app.run_async::<App>(
         bowl::micropub::Client::new(
             glib::Uri::parse(&std::env::var("MICROPUB_URI").unwrap(), glib::UriFlags::NONE).unwrap(),
             std::env::var("MICROPUB_TOKEN").unwrap(),