feat(attachmentspage): uploading & re-design (#66)

* feat(AttachmentsPage): use instance info supported mimes

* feat(AttachmentsPage): redesign

* feat(AttachmentsPage): AttachmentsPageAttachment

* feat: media upload + publish

* chore: push AttachmentsPageAttachment

* feat: toast overlay

* feat: attachment size check
This commit is contained in:
GeopJr 2023-02-01 19:02:56 +02:00 committed by GitHub
parent d3d3596210
commit 72388cf56e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 312 additions and 73 deletions

View File

@ -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);

View File

@ -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',

View File

@ -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<API.Attachment> (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<API.Attachment> (node);
// message (@"OK! ID $(entity.id)");
// return entity;
// }
// }
public override Gtk.Widget to_widget () {
if (preview_url != null) {
return new Widgets.Attachment.Image () {

View File

@ -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<string>.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);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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 ());
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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();
});