diff --git a/data/ui/dialogs/compose.ui b/data/ui/dialogs/compose.ui index bb6bf37..37aad2a 100644 --- a/data/ui/dialogs/compose.ui +++ b/data/ui/dialogs/compose.ui @@ -26,7 +26,7 @@ - Close + Cancel diff --git a/src/API/Attachment.vala b/src/API/Attachment.vala index a509d35..88bc5bb 100644 --- a/src/API/Attachment.vala +++ b/src/API/Attachment.vala @@ -1,33 +1,5 @@ public class Tootle.API.Attachment : Entity, Widgetizable { - // https://github.com/tootsuite/mastodon/blob/master/app/models/media_attachment.rb - public const string[] SUPPORTED_MIMES = { - "image/jpeg", - "image/png", - "image/gif", - "video/webm", - "video/mp4", - "video/quicktime", - "video/ogg", - "video/webm", - "video/quicktime", - "audio/wave", - "audio/wav", - "audio/x-wav", - "audio/x-pn-wave", - "audio/ogg", - "audio/mpeg", - "audio/mp3", - "audio/webm", - "audio/flac", - "audio/aac", - "audio/m4a", - "audio/x-m4a", - "audio/mp4", - "audio/3gpp", - "video/x-ms-asf" - }; - public string id { get; set; } public string kind { get; set; default = "unknown"; } public string url { get; set; } @@ -38,59 +10,73 @@ public class Tootle.API.Attachment : Entity, Widgetizable { get { return (this._preview_url == null || this._preview_url == "") ? url : _preview_url; } } - public static async Attachment upload (string uri, string title, string? descr) throws Error { - message (@"Uploading new media: $(uri)..."); + public File? source_file { get; set; } - uint8[] contents; - string mime; - GLib.FileInfo type; - try { - GLib.File file = File.new_for_uri (uri); - file.load_contents (null, out contents, null); - type = file.query_info (GLib.FileAttribute.STANDARD_CONTENT_TYPE, 0); - mime = type.get_content_type (); - } - catch (Error e) { - throw new Oopsie.USER (_("Can't open file $file:\n$reason") - .replace ("$file", title) - .replace ("$reason", e.message) - ); - } - - var descr_param = ""; - if (descr != null && descr.replace (" ", "") != "") { - descr_param = "?description=" + HtmlUtils.uri_encode (descr); - } - - var buffer = new Soup.Buffer.take (contents); - var multipart = new Soup.Multipart (Soup.FORM_MIME_TYPE_MULTIPART); - multipart.append_form_file ("file", mime.replace ("/", "."), mime, buffer); - var url = @"$(accounts.active.instance)/api/v1/media$descr_param"; - var msg = Soup.Form.request_new_from_multipart (url, multipart); - msg.request_headers.append ("Authorization", @"Bearer $(accounts.active.access_token)"); - - string? error = null; - network.queue (msg, - (sess, mess) => { - upload.callback (); - }, - (code, reason) => { - error = reason; - upload.callback (); - }); - - yield; - - if (error != null) - throw new Oopsie.INSTANCE (error); - else { - var node = network.parse_node (msg); - var entity = accounts.active.create_entity (node); - message (@"OK! ID $(entity.id)"); - return entity; + public bool is_published { + get { + return this.source_file == null; } } + public static Attachment upload (File file) { + return new Attachment () { + source_file = file + }; + } + + // public static async Attachment upload (string uri, string title, string? descr) throws Error { + // message (@"Uploading new media: $(uri)..."); + + // uint8[] contents; + // string mime; + // GLib.FileInfo type; + // try { + // GLib.File file = File.new_for_uri (uri); + // file.load_contents (null, out contents, null); + // type = file.query_info (GLib.FileAttribute.STANDARD_CONTENT_TYPE, 0); + // mime = type.get_content_type (); + // } + // catch (Error e) { + // throw new Oopsie.USER (_("Can't open file $file:\n$reason") + // .replace ("$file", title) + // .replace ("$reason", e.message) + // ); + // } + + // var descr_param = ""; + // if (descr != null && descr.replace (" ", "") != "") { + // descr_param = "?description=" + HtmlUtils.uri_encode (descr); + // } + + // var buffer = new Soup.Buffer.take (contents); + // var multipart = new Soup.Multipart (Soup.FORM_MIME_TYPE_MULTIPART); + // multipart.append_form_file ("file", mime.replace ("/", "."), mime, buffer); + // var url = @"$(accounts.active.instance)/api/v1/media$descr_param"; + // var msg = Soup.Form.request_new_from_multipart (url, multipart); + // msg.request_headers.append ("Authorization", @"Bearer $(accounts.active.access_token)"); + + // string? error = null; + // network.queue (msg, + // (sess, mess) => { + // upload.callback (); + // }, + // (code, reason) => { + // error = reason; + // upload.callback (); + // }); + + // yield; + + // if (error != null) + // throw new Oopsie.INSTANCE (error); + // else { + // var node = network.parse_node (msg); + // var entity = accounts.active.create_entity (node); + // message (@"OK! ID $(entity.id)"); + // return entity; + // } + // } + public override Gtk.Widget to_widget () { if (preview_url != null) { return new Widgets.Attachment.Image () { diff --git a/src/Dialogs/Composer/AttachmentsPage.vala b/src/Dialogs/Composer/AttachmentsPage.vala index b8190da..70c0d0b 100644 --- a/src/Dialogs/Composer/AttachmentsPage.vala +++ b/src/Dialogs/Composer/AttachmentsPage.vala @@ -1,10 +1,120 @@ +using Gtk; + public class Tootle.AttachmentsPage : ComposerPage { + // https://github.com/tootsuite/mastodon/blob/master/app/models/media_attachment.rb + public const string[] SUPPORTED_MIMES = { + "image/jpeg", + "image/png", + "image/gif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "video/webm", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/ogg", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf" + }; + + public GLib.ListStore attachments; + public AttachmentsPage () { Object ( title: _("Media"), icon_name: "mail-attachment-symbolic" ); + + attachments = new GLib.ListStore (typeof (API.Attachment)); + attachments.items_changed.connect (on_attachments_changed); + } + + protected Adw.ViewStack stack; + protected Adw.StatusPage empty_state; + protected ListBox list; + + public override void on_build (Dialogs.Compose dialog, API.Status status) { + base.on_build (dialog, status); + + // Empty state + var attach_button = new Button.with_label (_("Add Media")) { + halign = Align.CENTER + }; + attach_button.clicked.connect (show_file_selector); + + empty_state = new Adw.StatusPage () { + title = _("No Media"), + description = _("Drag files here or click the button below"), + vexpand = true, + icon_name = icon_name, + child = attach_button + }; + empty_state.add_css_class ("compact"); + + // Non-empty state + list = new ListBox (); + list.bind_model (attachments, on_create_list_item); + + // State stack + stack = new Adw.ViewStack (); + stack.add_named (list, "list"); + stack.add_named (empty_state, "empty"); + content.prepend (stack); + } + + public override void on_pull () { + on_attachments_changed (); + } + + Widget on_create_list_item (Object item) { + var attachment = item as API.Attachment; + return new Label (attachment.source_file.get_uri ()); + } + + void on_attachments_changed () { + var is_empty = attachments.get_n_items () < 1; + stack.visible_child_name = (is_empty ? "empty" : "list"); + } + + void show_file_selector () { + var filter = new FileFilter () { + name = _("All Supported Files") + }; + foreach (var mime_type in SUPPORTED_MIMES) { + filter.add_mime_type (mime_type); + } + + var chooser = new FileChooserNative (_("Open"), dialog, Gtk.FileChooserAction.OPEN, null, null) { + select_multiple = true, + filter = filter + }; + chooser.response.connect (id => { + switch (id) { + case ResponseType.ACCEPT: + var files = chooser.get_files (); + for (var i = 0; i < chooser.get_files ().get_n_items (); i++) { + var file = files.get_item (i) as File; + var attachment = API.Attachment.upload (file); + attachments.append (attachment); + } + break; + } + chooser.unref (); + }); + chooser.ref (); + chooser.show (); } } diff --git a/src/Dialogs/Composer/Dialog.vala b/src/Dialogs/Composer/Dialog.vala index 3f38bd2..df6bb45 100644 --- a/src/Dialogs/Composer/Dialog.vala +++ b/src/Dialogs/Composer/Dialog.vala @@ -13,13 +13,17 @@ public class Tootle.Dialogs.Compose : Adw.Window { set { commit_button.add_css_class (value); } } + ulong build_sigid; + construct { transient_for = app.main_window; title_switcher.stack = stack; - notify["status"].connect (() => { + build_sigid = notify["status"].connect (() => { build (); present (); + + disconnect (build_sigid); }); } @@ -80,7 +84,9 @@ public class Tootle.Dialogs.Compose : Adw.Window { protected void add_page (ComposerPage page) { var wrapper = stack.add (page); page.on_build (this, this.status); - modify_req.connect (page.on_sync); + page.on_pull (); + + modify_req.connect (page.on_push); modify_req.connect (page.on_modify_req); page.bind_property ("visible", wrapper, "visible", GLib.BindingFlags.SYNC_CREATE); page.bind_property ("title", wrapper, "title", GLib.BindingFlags.SYNC_CREATE); diff --git a/src/Dialogs/Composer/EditorPage.vala b/src/Dialogs/Composer/EditorPage.vala index 12231b8..fdf8faa 100644 --- a/src/Dialogs/Composer/EditorPage.vala +++ b/src/Dialogs/Composer/EditorPage.vala @@ -14,7 +14,6 @@ public class Tootle.EditorPage : ComposerPage { base.on_build (dialog, status); install_editor (); - populate_editor (); install_visibility (); install_cw (); @@ -27,9 +26,11 @@ public class Tootle.EditorPage : ComposerPage { recount_chars (); } - public override void on_sync () { - warning ("syncing"); + public override void on_pull () { + populate_editor (); + } + public override void on_push () { status.content = editor.buffer.text; status.sensitive = cw_button.active; if (status.sensitive) { diff --git a/src/Dialogs/Composer/Page.vala b/src/Dialogs/Composer/Page.vala index ac3adce..4834e3d 100644 --- a/src/Dialogs/Composer/Page.vala +++ b/src/Dialogs/Composer/Page.vala @@ -40,14 +40,16 @@ public class Tootle.ComposerPage : Gtk.Box { bottom_bar.show (); } - // This is used to populate the UI with the status entity data public virtual void on_build (Dialogs.Compose dialog, API.Status status) { this.dialog = dialog; this.status = status; } - // This is used to push data back to the status entity - public virtual void on_sync () {} + // Entity -> UI state + public virtual void on_pull () {} + + // UI state -> Entity + public virtual void on_push () {} public virtual void on_modify_req (Request req) {} diff --git a/src/Services/Network/Network.vala b/src/Services/Network/Network.vala index da57062..b47df90 100644 --- a/src/Services/Network/Network.vala +++ b/src/Services/Network/Network.vala @@ -5,92 +5,92 @@ using Json; public class Tootle.Network : GLib.Object { - public signal void started (); - public signal void finished (); + public signal void started (); + public signal void finished (); public delegate void ErrorCallback (int32 code, string reason); public delegate void SuccessCallback (Session session, Message msg) throws Error; public delegate void NodeCallback (Json.Node node, Message msg) throws Error; public delegate void ObjectCallback (Json.Object node) throws Error; - private int requests_processing = 0; - public Soup.Session session; + public Soup.Session session { get; set; } + int requests_processing = 0; - construct { - session = new Soup.Session () { - ssl_strict = true, - ssl_use_system_ca_file = true - }; - session.request_unqueued.connect (msg => { - requests_processing--; - if (requests_processing <= 0) - finished (); - }); - } + construct { + session = new Soup.Session () { + ssl_strict = true, + ssl_use_system_ca_file = true + }; + session.request_unqueued.connect (msg => { + requests_processing--; + if (requests_processing <= 0) + finished (); + }); + } - public void cancel (Soup.Message? msg) { - if (msg == null) - return; + public void cancel (Soup.Message? msg) { + if (msg == null) + return; - switch (msg.status_code) { - case Soup.Status.CANCELLED: - case Soup.Status.OK: - return; - } + switch (msg.status_code) { + case Soup.Status.CANCELLED: + case Soup.Status.OK: + return; + } - debug ("Cancelling message"); - session.cancel_message (msg, Soup.Status.CANCELLED); - } + debug ("Cancelling message"); + session.cancel_message (msg, Soup.Status.CANCELLED); + } - public void queue (owned Soup.Message mess, owned SuccessCallback cb, owned ErrorCallback ecb) { - requests_processing++; - started (); + public void queue (owned Soup.Message mess, owned SuccessCallback cb, owned ErrorCallback ecb) { + requests_processing++; + started (); message (@"$(mess.method): $(mess.uri.to_string (false))"); - try { - session.queue_message (mess, (sess, msg) => { - var status = msg.status_code; - if (status == Soup.Status.OK) - cb (session, msg); - else if (status == Soup.Status.CANCELLED) - debug ("Message is cancelled. Ignoring callback invocation."); - else - ecb ((int32) status, describe_error ((int32) status)); - }); - } - catch (Error e) { - warning (@"Exception in network queue: $(e.message)"); - ecb (0, e.message); - } - } + try { + session.queue_message (mess, (sess, msg) => { + var status = msg.status_code; + if (status == Soup.Status.OK) + cb (session, msg); + else if (status == Soup.Status.CANCELLED) + debug ("Message is cancelled. Ignoring callback invocation."); + else + ecb ((int32) status, describe_error ((int32) status)); + }); + } + catch (Error e) { + warning (@"Exception in network queue: $(e.message)"); + ecb (0, e.message); + } + } public string describe_error (uint code) { - var reason = Soup.Status.get_phrase (code); + var reason = Soup.Status.get_phrase (code); return @"$code: $reason"; } - public void on_error (int32 code, string message) { - warning (message); - app.toast (message); - } + public void on_error (int32 code, string message) { + warning (message); + app.toast (message); + } - public Json.Node parse_node (Soup.Message msg) throws Error { - var parser = new Json.Parser (); - parser.load_from_data ((string) msg.response_body.flatten ().data, -1); - return parser.get_root (); - } + public Json.Node parse_node (Soup.Message msg) throws Error { + var parser = new Json.Parser (); + parser.load_from_data ((string) msg.response_body.flatten ().data, -1); + return parser.get_root (); + } - public Json.Object parse (Soup.Message msg) throws Error { - return parse_node (msg).get_object (); - } + public Json.Object parse (Soup.Message msg) throws Error { + return parse_node (msg).get_object (); + } - public static void parse_array (Soup.Message msg, owned NodeCallback cb) throws Error { + public static void parse_array (Soup.Message msg, owned NodeCallback cb) throws Error { var parser = new Json.Parser (); parser.load_from_data ((string) msg.response_body.flatten ().data, -1); parser.get_root ().get_array ().foreach_element ((array, i, node) => { - cb (node, msg); + cb (node, msg); }); - } + } }