diff --git a/data/style.css b/data/style.css index 1ce602b..3388af9 100644 --- a/data/style.css +++ b/data/style.css @@ -182,6 +182,10 @@ headerbar.flat.no-title .title { padding: 6px 12px; } +.ttl-status-badge > .image-button { + padding: 3px 12px; +} + .ttl-verified-field { color: rgb(27,133,83); background: rgba(27,133,83,0.1); diff --git a/meson.build b/meson.build index 7b389c8..d1f2f92 100644 --- a/meson.build +++ b/meson.build @@ -81,6 +81,7 @@ sources = files( 'src/API/PollOption.vala', 'src/Application.vala', 'src/Dialogs/Composer/AttachmentsPage.vala', + 'src/Dialogs/Composer/AttachmentsPageAttachment.vala', 'src/Dialogs/Composer/Dialog.vala', 'src/Dialogs/Composer/EditorPage.vala', 'src/Dialogs/Composer/Page.vala', diff --git a/src/API/Attachment.vala b/src/API/Attachment.vala index e87d6d3..a3610d5 100644 --- a/src/API/Attachment.vala +++ b/src/API/Attachment.vala @@ -18,65 +18,57 @@ public class Tooth.API.Attachment : Entity, Widgetizable { } } - public static Attachment upload (File file) { - return new Attachment () { - source_file = file - }; + // public static Attachment upload (File file) { + // return new Attachment () { + // source_file = file + // }; + // } + + public static async Attachment upload (string uri) 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 %s:\n%s").printf (uri, e.message)); + } + + 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"; + 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 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 876f53a..0e6a07e 100644 --- a/src/Dialogs/Composer/AttachmentsPage.vala +++ b/src/Dialogs/Composer/AttachmentsPage.vala @@ -30,6 +30,8 @@ public class Tooth.AttachmentsPage : ComposerPage { }; public GLib.ListStore attachments; + public Adw.ToastOverlay toast_overlay; + public bool can_publish { get; set; default = false; } public AttachmentsPage () { Object ( @@ -52,6 +54,7 @@ public class Tooth.AttachmentsPage : ComposerPage { var attach_button = new Button.with_label (_("Add Media")) { halign = Align.CENTER }; + attach_button.add_css_class("pill"); attach_button.clicked.connect (show_file_selector); empty_state = new Adw.StatusPage () { @@ -67,11 +70,25 @@ public class Tooth.AttachmentsPage : ComposerPage { list = new ListBox (); list.bind_model (attachments, on_create_list_item); + var add_media_action_button = new Gtk.Button() { + icon_name = "tooth-plus-large-symbolic", + valign = Gtk.Align.CENTER, + halign = Gtk.Align.CENTER + }; + add_media_action_button.add_css_class ("flat"); + add_media_action_button.clicked.connect(show_file_selector); + + bottom_bar.pack_start (add_media_action_button); + // State stack stack = new Adw.ViewStack (); stack.add_named (list, "list"); stack.add_named (empty_state, "empty"); - content.prepend (stack); + + toast_overlay = new Adw.ToastOverlay(); + toast_overlay.child = stack; + + content.prepend (toast_overlay); } public override void on_pull () { @@ -80,19 +97,39 @@ public class Tooth.AttachmentsPage : ComposerPage { Widget on_create_list_item (Object item) { var attachment = item as API.Attachment; - return new Label (attachment.source_file.get_uri ()); + var attachment_widget = new AttachmentsPageAttachment(attachment.id, attachment.source_file, dialog); + attachment_widget.remove_from_model.connect(() => { + uint indx; + var found = attachments.find (item, out indx); + if (found) + attachments.remove(indx); + }); + return attachment_widget; } void on_attachments_changed () { var is_empty = attachments.get_n_items () < 1; - stack.visible_child_name = (is_empty ? "empty" : "list"); + if (is_empty) { + stack.visible_child_name = "empty"; + bottom_bar.hide (); + can_publish = false; + } else { + stack.visible_child_name = "list"; + bottom_bar.show (); + can_publish = true; + } } void show_file_selector () { var filter = new FileFilter () { name = _("All Supported Files") }; - foreach (var mime_type in SUPPORTED_MIMES) { + + var supported_mimes = new Gee.ArrayList.wrap(SUPPORTED_MIMES); + if (accounts.active.instance_info != null && accounts.active.instance_info.configuration != null && accounts.active.instance_info.configuration.media_attachments != null && accounts.active.instance_info.configuration.media_attachments.supported_mime_types != null && accounts.active.instance_info.configuration.media_attachments.supported_mime_types.size > 0) { + supported_mimes = accounts.active.instance_info.configuration.media_attachments.supported_mime_types; + } + foreach (var mime_type in supported_mimes) { filter.add_mime_type (mime_type); } @@ -104,10 +141,50 @@ public class Tooth.AttachmentsPage : ComposerPage { switch (id) { case ResponseType.ACCEPT: var files = chooser.get_files (); - for (var i = 0; i < chooser.get_files ().get_n_items (); i++) { + for (var i = 0; i < files.get_n_items (); i++) { var file = files.get_item (i) as File; - var attachment = API.Attachment.upload (file); - attachments.append (attachment); + + if (accounts.active.instance_info.compat_status_max_image_size > 0) { + try { + var file_info = file.query_info ("standard::size,standard::content-type", 0); + var file_content_type = file_info.get_content_type (); + + if (file_content_type != null) { + file_content_type = file_content_type.down(); + var file_size = file_info.get_size(); + var skip = (file_content_type.contains("image/") && + file_size >= accounts.active.instance_info.compat_status_max_image_size) || + (file_content_type.contains("video/") && + file_size >= accounts.active.instance_info.compat_status_max_video_size); + + if (skip) { + var toast = new Adw.Toast(_("File \"%s\" is bigger than the instance limit").printf(file.get_basename())) { + timeout = 0 + }; + toast_overlay.add_toast(toast); + continue; + } + } + + } catch (Error e) { + warning (e.message); + } + } + + API.Attachment.upload.begin (file.get_uri (), (obj, res) => { + try { + var attachment = API.Attachment.upload.end (res); + attachment.source_file = file; + attachments.append (attachment); + } + catch (Error e) { + warning (e.message); + var toast = new Adw.Toast(e.message) { + timeout = 0 + }; + toast_overlay.add_toast(toast); + } + }); } break; } @@ -117,4 +194,12 @@ public class Tooth.AttachmentsPage : ComposerPage { chooser.show (); } + public override void on_modify_req (Request req) { + if (can_publish){ + for (var i = 0; i < attachments.get_n_items (); i++) { + var attachment = attachments.get_item (i) as API.Attachment; + req.with_form_data ("media_ids[]", attachment.id); + } + } + } } diff --git a/src/Dialogs/Composer/AttachmentsPageAttachment.vala b/src/Dialogs/Composer/AttachmentsPageAttachment.vala new file mode 100644 index 0000000..971e4ac --- /dev/null +++ b/src/Dialogs/Composer/AttachmentsPageAttachment.vala @@ -0,0 +1,142 @@ +public class Tooth.AttachmentsPageAttachment : Widgets.Attachment.Item { + + protected Gtk.Picture pic; + protected File attachment_file; + protected string? alt_text { get; set; default = null; } + private const int ALT_MAX_CHARS = 1500; + private Dialogs.Compose compose_dialog; + protected string id; + + public AttachmentsPageAttachment (string attachment_id, File file, Dialogs.Compose dialog){ + id = attachment_id; + attachment_file = file; + compose_dialog = dialog; + pic = new Gtk.Picture.for_file (file) { + hexpand = true, + vexpand = true, + can_shrink = true, + keep_aspect_ratio = true + }; + button.child = pic; + badge.visible = false; + alt_btn.disconnect(alt_btn_clicked_id); + alt_btn.clicked.connect(() => { + create_alt_text_input_window().show(); + }); + alt_btn.add_css_class("error"); + alt_btn.remove_css_class("flat"); + + var delete_button = new Gtk.Button() { + icon_name = "tooth-trash-symbolic", + valign = Gtk.Align.CENTER, + halign = Gtk.Align.CENTER + }; + badge_box.append(delete_button); + delete_button.add_css_class("error"); + + delete_button.clicked.connect(() => remove_from_model()); + } + + public virtual signal void remove_from_model () {} + + protected override void on_rebind () {} + + protected override void on_secondary_click () {} + + protected override void on_click () { + Host.open_uri (attachment_file.get_path ()); + } + + protected bool validate(int text_size) { + // text_size > 0 && + return text_size <= ALT_MAX_CHARS; + } + + protected string remaining_alt_chars(int text_size) { + return (ALT_MAX_CHARS - text_size).to_string(); + } + + protected Adw.Window create_alt_text_input_window () { + var alt_editor = new Gtk.TextView () { + vexpand = true, + hexpand = true, + top_margin = 6, + right_margin = 6, + bottom_margin = 6, + left_margin = 6, + pixels_below_lines = 6, + accepts_tab = false, + wrap_mode = Gtk.WrapMode.WORD_CHAR + }; + var scroller = new Gtk.ScrolledWindow () { + hexpand = true, + vexpand = true + }; + scroller.child = alt_editor; + + var box = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + var headerbar = new Adw.HeaderBar(); + + var bottom_bar = new Gtk.ActionBar (); + var char_counter = new Gtk.Label (remaining_alt_chars(alt_text != null ? alt_text.length : 0)) { + margin_end = 6, + margin_top = 14, + margin_bottom = 14, + tooltip_text = _("Characters Left") + }; + char_counter.add_css_class ("heading"); + bottom_bar.pack_end (char_counter); + + var save_btn = new Gtk.Button.with_label(_("Save")); + save_btn.add_css_class("suggested-action"); + + save_btn.sensitive = alt_text != null && validate(alt_text.length); + + headerbar.pack_end(save_btn); + + box.append(headerbar); + box.append(scroller); + box.append(bottom_bar); + + if (alt_text != null) + alt_editor.buffer.text = alt_text; + alt_editor.buffer.changed.connect (() => { + var t_val = validate(alt_editor.buffer.get_char_count ()); + save_btn.sensitive = t_val; + char_counter.label = remaining_alt_chars(alt_editor.buffer.get_char_count ()); + if (t_val) { + char_counter.remove_css_class ("error"); + } else { + char_counter.add_css_class ("error"); + } + }); + + var dialog = new Adw.Window() { + modal = true, + title = @"Alternative text for attachment", + transient_for = compose_dialog, + content = box, + default_width = 400, + default_height = 300 + }; + + save_btn.clicked.connect(() => { + alt_text = alt_editor.buffer.text; + if (validate(alt_editor.buffer.get_char_count ()) && alt_editor.buffer.get_char_count () > 0) { + alt_btn.add_css_class("success"); + alt_btn.remove_css_class("error"); + } else { + alt_btn.remove_css_class("success"); + alt_btn.add_css_class("error"); + } + new Request.PUT (@"/api/v1/media/$(id)") + .with_account (accounts.active) + .with_param ("description", HtmlUtils.uri_encode (alt_text)) + .then(() => {}) + .exec (); + dialog.destroy(); + }); + + return dialog; + } +} diff --git a/src/Dialogs/Composer/Dialog.vala b/src/Dialogs/Composer/Dialog.vala index 5a67a51..5d8384b 100644 --- a/src/Dialogs/Composer/Dialog.vala +++ b/src/Dialogs/Composer/Dialog.vala @@ -29,10 +29,18 @@ public class Tooth.Dialogs.Compose : Adw.Window { protected virtual signal void build () { var p_edit = new EditorPage (); - p_edit.bind_property("can-publish", commit_button, "sensitive", BindingFlags.SYNC_CREATE); + var p_attach = new AttachmentsPage (); + p_edit.bind_property("can-publish", commit_button, "sensitive", BindingFlags.SYNC_CREATE, (b, src, ref target) => { + target.set_boolean (src.get_boolean() || p_attach.can_publish); + return true; + }); + p_attach.bind_property("can-publish", commit_button, "sensitive", BindingFlags.SYNC_CREATE, (b, src, ref target) => { + target.set_boolean (src.get_boolean() || p_edit.can_publish); + return true; + }); add_page (p_edit); - add_page (new AttachmentsPage ()); + add_page (p_attach); add_page (new PollPage ()); } diff --git a/src/Dialogs/Composer/EditorPage.vala b/src/Dialogs/Composer/EditorPage.vala index 4b0a38b..3516304 100644 --- a/src/Dialogs/Composer/EditorPage.vala +++ b/src/Dialogs/Composer/EditorPage.vala @@ -2,13 +2,18 @@ using Gtk; public class Tooth.EditorPage : ComposerPage { - protected uint char_limit { get; set; default = 500; } //TODO: Ask the instance to get this value - protected int remaining_chars { get; set; default = 0; } + protected int64 char_limit { get; set; default = 500; } + protected int64 remaining_chars { get; set; default = 0; } public bool can_publish { get; set; default = false; } construct { title = _("Text"); icon_name = "tooth-edit-symbolic"; + + var char_limit_api = accounts.active.instance_info.compat_status_max_characters; + if (char_limit_api > 0) + char_limit = char_limit_api; + remaining_chars = char_limit; } public override void on_build (Dialogs.Compose dialog, API.Status status) { @@ -46,7 +51,8 @@ public class Tooth.EditorPage : ComposerPage { } public override void on_modify_req (Request req) { - req.with_form_data ("status", status.content); + if (can_publish) + req.with_form_data ("status", status.content); req.with_form_data ("visibility", status.visibility); if (dialog.status.in_reply_to_id != null) @@ -67,7 +73,7 @@ public class Tooth.EditorPage : ComposerPage { protected void install_editor () { recount_chars.connect (() => { - remaining_chars = (int) char_limit; + remaining_chars = char_limit; }); recount_chars.connect_after (() => { placeholder.visible = remaining_chars == char_limit; diff --git a/src/Widgets/Attachment/Image.vala b/src/Widgets/Attachment/Image.vala index 5719d93..9bec6bc 100644 --- a/src/Widgets/Attachment/Image.vala +++ b/src/Widgets/Attachment/Image.vala @@ -24,5 +24,4 @@ public class Tooth.Widgets.Attachment.Image : Widgets.Attachment.Item { protected virtual void on_cache_response (bool is_loaded, owned Paintable? data) { pic.paintable = data; } - } diff --git a/src/Widgets/Attachment/Item.vala b/src/Widgets/Attachment/Item.vala index 0ce89e0..d564881 100644 --- a/src/Widgets/Attachment/Item.vala +++ b/src/Widgets/Attachment/Item.vala @@ -17,6 +17,8 @@ public class Tooth.Widgets.Attachment.Item : Adw.Bin { protected Button button; protected Button alt_btn; protected Label badge; + protected Gtk.Box badge_box; + protected ulong alt_btn_clicked_id; private void copy_url () { Host.copy (entity.url); @@ -87,7 +89,7 @@ public class Tooth.Widgets.Attachment.Item : Adw.Bin { gesture_click_controller.pressed.connect(on_secondary_click); gesture_lp_controller.pressed.connect(on_secondary_click); - var badge_box = new Gtk.Box(Orientation.HORIZONTAL, 1) { + badge_box = new Gtk.Box(Orientation.HORIZONTAL, 1) { valign = Align.END, halign = Align.START }; @@ -98,7 +100,7 @@ public class Tooth.Widgets.Attachment.Item : Adw.Bin { alt_btn.add_css_class ("heading"); alt_btn.add_css_class ("flat"); - alt_btn.clicked.connect(() => { + alt_btn_clicked_id = alt_btn.clicked.connect(() => { if (entity != null && entity.description != null) create_alt_text_window(entity.description).show(); });