Composer: Initial work for attachments
This commit is contained in:
parent
0c10bee64d
commit
ddba63d805
|
@ -26,7 +26,7 @@
|
||||||
</child>
|
</child>
|
||||||
<child type="start">
|
<child type="start">
|
||||||
<object class="GtkButton" id="close_button">
|
<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"/>
|
<signal name="clicked" handler="on_close" swapped="no"/>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
|
@ -1,33 +1,5 @@
|
||||||
public class Tootle.API.Attachment : Entity, Widgetizable {
|
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 id { get; set; }
|
||||||
public string kind { get; set; default = "unknown"; }
|
public string kind { get; set; default = "unknown"; }
|
||||||
public string url { get; set; }
|
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; }
|
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 {
|
public File? source_file { get; set; }
|
||||||
message (@"Uploading new media: $(uri)...");
|
|
||||||
|
|
||||||
uint8[] contents;
|
public bool is_published {
|
||||||
string mime;
|
get {
|
||||||
GLib.FileInfo type;
|
return this.source_file == null;
|
||||||
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 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 () {
|
public override Gtk.Widget to_widget () {
|
||||||
if (preview_url != null) {
|
if (preview_url != null) {
|
||||||
return new Widgets.Attachment.Image () {
|
return new Widgets.Attachment.Image () {
|
||||||
|
|
|
@ -1,10 +1,120 @@
|
||||||
|
using Gtk;
|
||||||
|
|
||||||
public class Tootle.AttachmentsPage : ComposerPage {
|
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 () {
|
public AttachmentsPage () {
|
||||||
Object (
|
Object (
|
||||||
title: _("Media"),
|
title: _("Media"),
|
||||||
icon_name: "mail-attachment-symbolic"
|
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 ();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,13 +13,17 @@ public class Tootle.Dialogs.Compose : Adw.Window {
|
||||||
set { commit_button.add_css_class (value); }
|
set { commit_button.add_css_class (value); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ulong build_sigid;
|
||||||
|
|
||||||
construct {
|
construct {
|
||||||
transient_for = app.main_window;
|
transient_for = app.main_window;
|
||||||
title_switcher.stack = stack;
|
title_switcher.stack = stack;
|
||||||
|
|
||||||
notify["status"].connect (() => {
|
build_sigid = notify["status"].connect (() => {
|
||||||
build ();
|
build ();
|
||||||
present ();
|
present ();
|
||||||
|
|
||||||
|
disconnect (build_sigid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,7 +84,9 @@ public class Tootle.Dialogs.Compose : Adw.Window {
|
||||||
protected void add_page (ComposerPage page) {
|
protected void add_page (ComposerPage page) {
|
||||||
var wrapper = stack.add (page);
|
var wrapper = stack.add (page);
|
||||||
page.on_build (this, this.status);
|
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);
|
modify_req.connect (page.on_modify_req);
|
||||||
page.bind_property ("visible", wrapper, "visible", GLib.BindingFlags.SYNC_CREATE);
|
page.bind_property ("visible", wrapper, "visible", GLib.BindingFlags.SYNC_CREATE);
|
||||||
page.bind_property ("title", wrapper, "title", GLib.BindingFlags.SYNC_CREATE);
|
page.bind_property ("title", wrapper, "title", GLib.BindingFlags.SYNC_CREATE);
|
||||||
|
|
|
@ -14,7 +14,6 @@ public class Tootle.EditorPage : ComposerPage {
|
||||||
base.on_build (dialog, status);
|
base.on_build (dialog, status);
|
||||||
|
|
||||||
install_editor ();
|
install_editor ();
|
||||||
populate_editor ();
|
|
||||||
install_visibility ();
|
install_visibility ();
|
||||||
install_cw ();
|
install_cw ();
|
||||||
|
|
||||||
|
@ -27,9 +26,11 @@ public class Tootle.EditorPage : ComposerPage {
|
||||||
recount_chars ();
|
recount_chars ();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void on_sync () {
|
public override void on_pull () {
|
||||||
warning ("syncing");
|
populate_editor ();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void on_push () {
|
||||||
status.content = editor.buffer.text;
|
status.content = editor.buffer.text;
|
||||||
status.sensitive = cw_button.active;
|
status.sensitive = cw_button.active;
|
||||||
if (status.sensitive) {
|
if (status.sensitive) {
|
||||||
|
|
|
@ -40,14 +40,16 @@ public class Tootle.ComposerPage : Gtk.Box {
|
||||||
bottom_bar.show ();
|
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) {
|
public virtual void on_build (Dialogs.Compose dialog, API.Status status) {
|
||||||
this.dialog = dialog;
|
this.dialog = dialog;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is used to push data back to the status entity
|
// Entity -> UI state
|
||||||
public virtual void on_sync () {}
|
public virtual void on_pull () {}
|
||||||
|
|
||||||
|
// UI state -> Entity
|
||||||
|
public virtual void on_push () {}
|
||||||
|
|
||||||
public virtual void on_modify_req (Request req) {}
|
public virtual void on_modify_req (Request req) {}
|
||||||
|
|
||||||
|
|
|
@ -5,92 +5,92 @@ using Json;
|
||||||
|
|
||||||
public class Tootle.Network : GLib.Object {
|
public class Tootle.Network : GLib.Object {
|
||||||
|
|
||||||
public signal void started ();
|
public signal void started ();
|
||||||
public signal void finished ();
|
public signal void finished ();
|
||||||
|
|
||||||
public delegate void ErrorCallback (int32 code, string reason);
|
public delegate void ErrorCallback (int32 code, string reason);
|
||||||
public delegate void SuccessCallback (Session session, Message msg) throws Error;
|
public delegate void SuccessCallback (Session session, Message msg) throws Error;
|
||||||
public delegate void NodeCallback (Json.Node node, Message msg) throws Error;
|
public delegate void NodeCallback (Json.Node node, Message msg) throws Error;
|
||||||
public delegate void ObjectCallback (Json.Object node) throws Error;
|
public delegate void ObjectCallback (Json.Object node) throws Error;
|
||||||
|
|
||||||
private int requests_processing = 0;
|
public Soup.Session session { get; set; }
|
||||||
public Soup.Session session;
|
int requests_processing = 0;
|
||||||
|
|
||||||
construct {
|
construct {
|
||||||
session = new Soup.Session () {
|
session = new Soup.Session () {
|
||||||
ssl_strict = true,
|
ssl_strict = true,
|
||||||
ssl_use_system_ca_file = true
|
ssl_use_system_ca_file = true
|
||||||
};
|
};
|
||||||
session.request_unqueued.connect (msg => {
|
session.request_unqueued.connect (msg => {
|
||||||
requests_processing--;
|
requests_processing--;
|
||||||
if (requests_processing <= 0)
|
if (requests_processing <= 0)
|
||||||
finished ();
|
finished ();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void cancel (Soup.Message? msg) {
|
public void cancel (Soup.Message? msg) {
|
||||||
if (msg == null)
|
if (msg == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
switch (msg.status_code) {
|
switch (msg.status_code) {
|
||||||
case Soup.Status.CANCELLED:
|
case Soup.Status.CANCELLED:
|
||||||
case Soup.Status.OK:
|
case Soup.Status.OK:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug ("Cancelling message");
|
debug ("Cancelling message");
|
||||||
session.cancel_message (msg, Soup.Status.CANCELLED);
|
session.cancel_message (msg, Soup.Status.CANCELLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void queue (owned Soup.Message mess, owned SuccessCallback cb, owned ErrorCallback ecb) {
|
public void queue (owned Soup.Message mess, owned SuccessCallback cb, owned ErrorCallback ecb) {
|
||||||
requests_processing++;
|
requests_processing++;
|
||||||
started ();
|
started ();
|
||||||
|
|
||||||
message (@"$(mess.method): $(mess.uri.to_string (false))");
|
message (@"$(mess.method): $(mess.uri.to_string (false))");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
session.queue_message (mess, (sess, msg) => {
|
session.queue_message (mess, (sess, msg) => {
|
||||||
var status = msg.status_code;
|
var status = msg.status_code;
|
||||||
if (status == Soup.Status.OK)
|
if (status == Soup.Status.OK)
|
||||||
cb (session, msg);
|
cb (session, msg);
|
||||||
else if (status == Soup.Status.CANCELLED)
|
else if (status == Soup.Status.CANCELLED)
|
||||||
debug ("Message is cancelled. Ignoring callback invocation.");
|
debug ("Message is cancelled. Ignoring callback invocation.");
|
||||||
else
|
else
|
||||||
ecb ((int32) status, describe_error ((int32) status));
|
ecb ((int32) status, describe_error ((int32) status));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Error e) {
|
catch (Error e) {
|
||||||
warning (@"Exception in network queue: $(e.message)");
|
warning (@"Exception in network queue: $(e.message)");
|
||||||
ecb (0, e.message);
|
ecb (0, e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string describe_error (uint code) {
|
public string describe_error (uint code) {
|
||||||
var reason = Soup.Status.get_phrase (code);
|
var reason = Soup.Status.get_phrase (code);
|
||||||
return @"$code: $reason";
|
return @"$code: $reason";
|
||||||
}
|
}
|
||||||
|
|
||||||
public void on_error (int32 code, string message) {
|
public void on_error (int32 code, string message) {
|
||||||
warning (message);
|
warning (message);
|
||||||
app.toast (message);
|
app.toast (message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Json.Node parse_node (Soup.Message msg) throws Error {
|
public Json.Node parse_node (Soup.Message msg) throws Error {
|
||||||
var parser = new Json.Parser ();
|
var parser = new Json.Parser ();
|
||||||
parser.load_from_data ((string) msg.response_body.flatten ().data, -1);
|
parser.load_from_data ((string) msg.response_body.flatten ().data, -1);
|
||||||
return parser.get_root ();
|
return parser.get_root ();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Json.Object parse (Soup.Message msg) throws Error {
|
public Json.Object parse (Soup.Message msg) throws Error {
|
||||||
return parse_node (msg).get_object ();
|
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 ();
|
var parser = new Json.Parser ();
|
||||||
parser.load_from_data ((string) msg.response_body.flatten ().data, -1);
|
parser.load_from_data ((string) msg.response_body.flatten ().data, -1);
|
||||||
parser.get_root ().get_array ().foreach_element ((array, i, node) => {
|
parser.get_root ().get_array ().foreach_element ((array, i, node) => {
|
||||||
cb (node, msg);
|
cb (node, msg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue