Composer: Initial work for attachments

This commit is contained in:
Bleak Grey 2021-08-31 10:54:11 +03:00
parent 0c10bee64d
commit ddba63d805
7 changed files with 252 additions and 147 deletions

View File

@ -26,7 +26,7 @@
</child>
<child type="start">
<object class="GtkButton" id="close_button">
<property name="label" translatable="yes">Close</property>
<property name="label" translatable="yes">Cancel</property>
<signal name="clicked" handler="on_close" swapped="no"/>
</object>
</child>

View File

@ -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<API.Attachment> (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<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

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

View File

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

View File

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

View File

@ -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) {}

View File

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