From 9848d170af199efe498c1af6eb6c2b3459b3455d Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Tue, 24 Mar 2026 02:33:05 +0200 Subject: [PATCH 1/8] experiment: requests v2 --- src/Services/Network/Network.vala | 43 +++++++- src/Services/Network/RequestV2.vala | 158 ++++++++++++++++++++++++++++ src/Services/Network/meson.build | 1 + src/Views/Timeline.vala | 59 +++++++---- 4 files changed, 237 insertions(+), 24 deletions(-) create mode 100644 src/Services/Network/RequestV2.vala diff --git a/src/Services/Network/Network.vala b/src/Services/Network/Network.vala index 786cb3fda..8594a7e73 100644 --- a/src/Services/Network/Network.vala +++ b/src/Services/Network/Network.vala @@ -1,5 +1,4 @@ public class Tuba.Network : GLib.Object { - public signal void started (); public signal void finished (); @@ -106,6 +105,42 @@ public class Tuba.Network : GLib.Object { }); } + public async bool queue_v2 ( + owned Soup.Message msg, + GLib.Cancellable? cancellable, + out GLib.InputStream in_stream, + out Soup.MessageHeaders response_headers + ) throws GLib.Error, Oopsie { + requests_processing++; + + in_stream = yield session.send_async (msg, 0, cancellable); + var status = msg.status_code; + response_headers = msg.response_headers; + + if (status >= 200 && status < 300) { + return true; + } else if (status == GLib.IOError.CANCELLED) { + debug ("Message is cancelled."); + } else { + string error_msg = msg.reason_phrase; + + try { + var parser = Network.get_parser_from_inputstream (in_stream); + var root = network.parse (parser); + if (root != null) { + error_msg = root.has_member ("message") + ? root.get_string_member_with_default ("message", msg.reason_phrase) + : root.get_string_member_with_default ("error", msg.reason_phrase); + } + } catch {} + + critical (@"Request \"$(msg.uri.to_string ())\" failed: $status $(msg.reason_phrase) $error_msg"); + throw new Oopsie.INSTANCE (error_msg); + } + + return false; + } + public void on_error (int32 code, string message) { warning (message); app.toast (message, 0); @@ -128,6 +163,12 @@ public class Tuba.Network : GLib.Object { return parser; } + public static async Json.Parser get_parser_from_inputstream_async (InputStream in_stream) throws Error { + var parser = new Json.Parser (); + yield parser.load_from_stream_async (in_stream); + return parser; + } + public static Json.Array? get_array_mstd (Json.Parser parser) { return parser.get_root ().get_array (); } diff --git a/src/Services/Network/RequestV2.vala b/src/Services/Network/RequestV2.vala new file mode 100644 index 000000000..cd26fa41e --- /dev/null +++ b/src/Services/Network/RequestV2.vala @@ -0,0 +1,158 @@ +public class Tuba.RequestV2 : GLib.Object { + public enum Method { + GET, + POST, + PUT, + DELETE, + PATCH; + + public string to_string () { + switch (this) { + case GET: return "GET"; + case POST: return "POST"; + case PUT: return "PUT"; + case DELETE: return "DELETE"; + case PATCH: return "PATCH"; + default: assert_not_reached (); + } + } + } + + public Method method { get; private set; default = GET; } + public string url { get; private set; } + public Soup.MessagePriority priority { get; set; default = NORMAL; } + public GLib.Cancellable? cancellable { get; set; default = null; } // priv? + public InstanceAccount? account { get; set; default = null; } + public string? force_token { get; set; default = null; } + public bool no_auth { get; set; default = false; } + public bool cache { get; set; default = true; } + public weak Gtk.Widget? ctx { + set { + this._ctx = value; + if (this._ctx != null) { + this._ctx.destroy.connect (on_ctx_destroy); + } + } + } + + private GLib.HashTable parameters = new GLib.HashTable (str_hash, str_equal); + private string? content_type { get; set; default = null; } + private weak Gtk.Widget? _ctx = null; + private Soup.Multipart? form_data = null; + private Bytes? body_bytes = null; + + private void on_ctx_destroy () { + this.cancellable.cancel (); + this.ctx = null; + } + + public RequestV2 (string url, Method method = GET) { + this.method = method; + this.url = url; + } + + public void add_parameter (string key, string value) { + parameters.insert ( + GLib.Uri.escape_string (key, "[]"), + GLib.Uri.escape_string (value) + ); + } + + public bool remove_parameter (string key) { + if (!this.parameters.contains (key)) return false; + + return parameters.foreach_remove ((p_key, p_val) => { + return p_key == key || p_key == @"$key[]"; + }) > 0; + } + + public void add_parameter_array (string key, string[] values) { + if (values.length == 0) return; + + string final_key = key; + if (!final_key.has_suffix ("[]")) final_key = @"$key[]"; + foreach (string value in values) { + add_parameter (final_key, value); + } + } + + public void add_form_data (string name, string val) { + if (this.form_data == null) + this.form_data = new Soup.Multipart (Soup.FORM_MIME_TYPE_MULTIPART); + this.form_data.append_form_string (name, val); + } + + public void add_form_data_file (string name, string mime, Bytes buffer) { + if (this.form_data == null) + this.form_data = new Soup.Multipart (Soup.FORM_MIME_TYPE_MULTIPART); + this.form_data.append_form_file (name, mime.replace ("/", "."), mime, buffer); + } + + public void set_body (string? content_type, Bytes? bytes) { + this.content_type = content_type; + body_bytes = bytes; + } + + public void set_body_from_json (Json.Builder json_builder) { + Json.Generator generator = new Json.Generator (); + generator.set_root (json_builder.get_root ()); + set_body ("application/json", new Bytes.take (generator.to_data (null).data)); + } + + public async bool exec (out GLib.InputStream in_stream, out Soup.MessageHeaders response_headers) throws GLib.Error, Oopsie { + if (this.cancellable != null && !this.cancellable.is_cancelled ()) this.cancellable.cancel (); + this.cancellable = new GLib.Cancellable (); + + string final_url = build_final_url (); + Soup.Message message; + if (this.form_data == null) { + GLib.Uri final_uri = GLib.Uri.parse (final_url, UriFlags.ENCODED_PATH | UriFlags.ENCODED_QUERY); + message = new Soup.Message.from_uri (this.method.to_string (), final_uri); + } else { + message = new Soup.Message.from_multipart (final_url, this.form_data); + // POST is default for multipart + if (this.method != POST) message.method = this.method.to_string (); + } + + if (!no_auth) { + if (force_token != null) { + message.request_headers.append ("Authorization", @"Bearer $force_token"); + } else if (account != null && account.access_token != null) { + message.request_headers.append ("Authorization", @"Bearer $(account.access_token)"); + } + } else { + message.request_headers.remove ("Authorization"); + } + + if (!cache) message.disable_feature (typeof (Soup.Cache)); + message.priority = priority; + + if (this.content_type != null && this.body_bytes != null) + message.set_request_body_from_bytes (this.content_type, this.body_bytes); + + return yield network.queue_v2 (message, this.cancellable, out in_stream, out response_headers); + // TODO: ensure body_bytes = ctx = null... + } + + private string build_final_url () { + string final_url = this.account != null && this.url.has_prefix ("/") + ? @"$(this.account.instance)$(this.url)" + : this.url; + final_url += @"$("?" in this.url ? "&" : "?")$(parameters_to_string ())"; + return final_url; + } + + private string parameters_to_string () { + string res = ""; + if (this.parameters.length == 0) return res; + + int i = 0; + this.parameters.foreach ((key, val) => { + i++; + res += @"$key=$val"; // already escaped + if (i < this.parameters.length) res += "&"; + }); + + return (owned) res; + } +} diff --git a/src/Services/Network/meson.build b/src/Services/Network/meson.build index 55161e68e..60fbe20de 100644 --- a/src/Services/Network/meson.build +++ b/src/Services/Network/meson.build @@ -1,6 +1,7 @@ sources += files( 'Network.vala', 'Request.vala', + 'RequestV2.vala', 'Streamable.vala', 'Streams.vala', ) diff --git a/src/Views/Timeline.vala b/src/Views/Timeline.vala index 46c911927..8780e1b7b 100644 --- a/src/Views/Timeline.vala +++ b/src/Views/Timeline.vala @@ -212,33 +212,46 @@ public class Tuba.Views.Timeline : AccountHolder, Streamable, Views.ContentBase } public virtual bool request () { - append_params (new Request.GET (get_req_url ())) - .with_account (account) - .with_ctx (this) - .with_extra_data (Tuba.Network.ExtraData.RESPONSE_HEADERS) - .then ((in_stream, headers) => { - var parser = Network.get_parser_from_inputstream (in_stream); - - Object[] to_add = {}; - Network.parse_array (parser, node => { - var e = Tuba.Helper.Entity.from_json (node, accepts); - if (!(should_hide (e))) to_add += e; - }); - model.splice (model.get_n_items (), 0, to_add); - - if (headers != null) - get_pages (headers.get_one ("Link")); - - if (to_add.length == 0) - on_content_changed (); - on_request_finish (); - }) - .on_error (on_error) - .exec (); + var req = new RequestV2 (get_req_url ()) { + account = account, + ctx = this + }; + + if (page_next == null) + req.add_parameter ("limit", settings.timeline_page_size.clamp (this.batch_size_min, 40).to_string ()); + + request_async.begin (req, (obj, res) => { + try { + request_async.end (res); + } catch (GLib.Error e) { + on_error (e.code, e.message); + } + }); return GLib.Source.REMOVE; } + private async void request_async (RequestV2 req) throws Error, Oopsie { + GLib.InputStream in_stream; + Soup.MessageHeaders response_headers; + if (!(yield req.exec (out in_stream, out response_headers))) return; + + var parser = yield Network.get_parser_from_inputstream_async (in_stream); + Object[] to_add = {}; + Network.parse_array (parser, node => { + var e = Helper.Entity.from_json (node, accepts); + if (!(should_hide (e))) to_add += e; + }); + model.splice (model.get_n_items (), 0, to_add); + + if (response_headers != null) + get_pages (response_headers.get_one ("Link")); + + if (to_add.length == 0) + on_content_changed (); + on_request_finish (); + } + public override void on_error (int32 code, string reason) { if (base_status == null) { warning (@"Error while refreshing $label: $code $reason"); From ab22056fd9816d0e020b0c3e17f23387affd4865 Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Tue, 24 Mar 2026 14:58:52 +0200 Subject: [PATCH 2/8] fix(request): escape key on remove_parameter --- src/Services/Network/RequestV2.vala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Services/Network/RequestV2.vala b/src/Services/Network/RequestV2.vala index cd26fa41e..4ea607be7 100644 --- a/src/Services/Network/RequestV2.vala +++ b/src/Services/Network/RequestV2.vala @@ -59,10 +59,11 @@ public class Tuba.RequestV2 : GLib.Object { } public bool remove_parameter (string key) { - if (!this.parameters.contains (key)) return false; + string final_key = GLib.Uri.escape_string (key, "[]"); + if (!this.parameters.contains (final_key)) return false; return parameters.foreach_remove ((p_key, p_val) => { - return p_key == key || p_key == @"$key[]"; + return p_key == final_key || p_key == @"$final_key[]"; }) > 0; } From 7970c9ac57277d509e40a9bbe7f2cb90a489ddd5 Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Tue, 24 Mar 2026 15:00:24 +0200 Subject: [PATCH 3/8] fix(request): foreach ownership --- src/Services/Network/RequestV2.vala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Services/Network/RequestV2.vala b/src/Services/Network/RequestV2.vala index 4ea607be7..4c05c36cd 100644 --- a/src/Services/Network/RequestV2.vala +++ b/src/Services/Network/RequestV2.vala @@ -72,7 +72,7 @@ public class Tuba.RequestV2 : GLib.Object { string final_key = key; if (!final_key.has_suffix ("[]")) final_key = @"$key[]"; - foreach (string value in values) { + foreach (unowned string value in values) { add_parameter (final_key, value); } } From 07e60fbd59921707557895f9e402d6ebd5cf6229 Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Tue, 24 Mar 2026 15:07:38 +0200 Subject: [PATCH 4/8] feat: queue_v2 improvements - async error parsing - remove useless cancelled branch - bool => void return type - handle cancelled properly --- src/Services/Network/Network.vala | 35 ++++++++++++----------------- src/Services/Network/RequestV2.vala | 4 ++-- src/Views/Timeline.vala | 7 +++++- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/Services/Network/Network.vala b/src/Services/Network/Network.vala index 8594a7e73..a289977eb 100644 --- a/src/Services/Network/Network.vala +++ b/src/Services/Network/Network.vala @@ -105,7 +105,7 @@ public class Tuba.Network : GLib.Object { }); } - public async bool queue_v2 ( + public async void queue_v2 ( owned Soup.Message msg, GLib.Cancellable? cancellable, out GLib.InputStream in_stream, @@ -117,28 +117,21 @@ public class Tuba.Network : GLib.Object { var status = msg.status_code; response_headers = msg.response_headers; - if (status >= 200 && status < 300) { - return true; - } else if (status == GLib.IOError.CANCELLED) { - debug ("Message is cancelled."); - } else { - string error_msg = msg.reason_phrase; + if (status >= 200 && status < 300) return; - try { - var parser = Network.get_parser_from_inputstream (in_stream); - var root = network.parse (parser); - if (root != null) { - error_msg = root.has_member ("message") - ? root.get_string_member_with_default ("message", msg.reason_phrase) - : root.get_string_member_with_default ("error", msg.reason_phrase); - } - } catch {} - - critical (@"Request \"$(msg.uri.to_string ())\" failed: $status $(msg.reason_phrase) $error_msg"); - throw new Oopsie.INSTANCE (error_msg); - } + unowned string error_msg = msg.reason_phrase; + try { + var parser = yield Network.get_parser_from_inputstream_async (in_stream); + var root = network.parse (parser); + if (root != null) { + error_msg = root.has_member ("message") + ? root.get_string_member_with_default ("message", msg.reason_phrase) + : root.get_string_member_with_default ("error", msg.reason_phrase); + } + } catch {} - return false; + critical (@"Request \"$(msg.uri.to_string ())\" failed: $status $(msg.reason_phrase) $error_msg"); + throw new Oopsie.INSTANCE (error_msg); } public void on_error (int32 code, string message) { diff --git a/src/Services/Network/RequestV2.vala b/src/Services/Network/RequestV2.vala index 4c05c36cd..e053c979b 100644 --- a/src/Services/Network/RequestV2.vala +++ b/src/Services/Network/RequestV2.vala @@ -100,7 +100,7 @@ public class Tuba.RequestV2 : GLib.Object { set_body ("application/json", new Bytes.take (generator.to_data (null).data)); } - public async bool exec (out GLib.InputStream in_stream, out Soup.MessageHeaders response_headers) throws GLib.Error, Oopsie { + public async void exec (out GLib.InputStream in_stream, out Soup.MessageHeaders response_headers) throws GLib.Error, Oopsie { if (this.cancellable != null && !this.cancellable.is_cancelled ()) this.cancellable.cancel (); this.cancellable = new GLib.Cancellable (); @@ -131,7 +131,7 @@ public class Tuba.RequestV2 : GLib.Object { if (this.content_type != null && this.body_bytes != null) message.set_request_body_from_bytes (this.content_type, this.body_bytes); - return yield network.queue_v2 (message, this.cancellable, out in_stream, out response_headers); + yield network.queue_v2 (message, this.cancellable, out in_stream, out response_headers); // TODO: ensure body_bytes = ctx = null... } diff --git a/src/Views/Timeline.vala b/src/Views/Timeline.vala index 8780e1b7b..f56e6fad9 100644 --- a/src/Views/Timeline.vala +++ b/src/Views/Timeline.vala @@ -224,6 +224,11 @@ public class Tuba.Views.Timeline : AccountHolder, Streamable, Views.ContentBase try { request_async.end (res); } catch (GLib.Error e) { + if (e is GLib.IOError.CANCELLED) { + debug ("Message is cancelled."); + return; + } + on_error (e.code, e.message); } }); @@ -234,7 +239,7 @@ public class Tuba.Views.Timeline : AccountHolder, Streamable, Views.ContentBase private async void request_async (RequestV2 req) throws Error, Oopsie { GLib.InputStream in_stream; Soup.MessageHeaders response_headers; - if (!(yield req.exec (out in_stream, out response_headers))) return; + yield req.exec (out in_stream, out response_headers); var parser = yield Network.get_parser_from_inputstream_async (in_stream); Object[] to_add = {}; From 184380ece8356d3afae8235e3c00b0252a4119c1 Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Tue, 24 Mar 2026 15:08:25 +0200 Subject: [PATCH 5/8] fix(request): remove useless cancellable cancelled check --- src/Services/Network/RequestV2.vala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Services/Network/RequestV2.vala b/src/Services/Network/RequestV2.vala index e053c979b..d3680ea3f 100644 --- a/src/Services/Network/RequestV2.vala +++ b/src/Services/Network/RequestV2.vala @@ -101,7 +101,7 @@ public class Tuba.RequestV2 : GLib.Object { } public async void exec (out GLib.InputStream in_stream, out Soup.MessageHeaders response_headers) throws GLib.Error, Oopsie { - if (this.cancellable != null && !this.cancellable.is_cancelled ()) this.cancellable.cancel (); + if (this.cancellable != null) this.cancellable.cancel (); this.cancellable = new GLib.Cancellable (); string final_url = build_final_url (); From cabfd2c580a2c1e556c577ba2b603235ee7f3dfc Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Tue, 24 Mar 2026 15:14:50 +0200 Subject: [PATCH 6/8] feat(Timeline): error handling in async --- src/Views/Timeline.vala | 57 +++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/src/Views/Timeline.vala b/src/Views/Timeline.vala index f56e6fad9..33d99b616 100644 --- a/src/Views/Timeline.vala +++ b/src/Views/Timeline.vala @@ -220,41 +220,38 @@ public class Tuba.Views.Timeline : AccountHolder, Streamable, Views.ContentBase if (page_next == null) req.add_parameter ("limit", settings.timeline_page_size.clamp (this.batch_size_min, 40).to_string ()); - request_async.begin (req, (obj, res) => { - try { - request_async.end (res); - } catch (GLib.Error e) { - if (e is GLib.IOError.CANCELLED) { - debug ("Message is cancelled."); - return; - } - - on_error (e.code, e.message); - } - }); - + request_async.begin (req); return GLib.Source.REMOVE; } - private async void request_async (RequestV2 req) throws Error, Oopsie { + private async void request_async (RequestV2 req) { GLib.InputStream in_stream; Soup.MessageHeaders response_headers; - yield req.exec (out in_stream, out response_headers); - - var parser = yield Network.get_parser_from_inputstream_async (in_stream); - Object[] to_add = {}; - Network.parse_array (parser, node => { - var e = Helper.Entity.from_json (node, accepts); - if (!(should_hide (e))) to_add += e; - }); - model.splice (model.get_n_items (), 0, to_add); - - if (response_headers != null) - get_pages (response_headers.get_one ("Link")); - - if (to_add.length == 0) - on_content_changed (); - on_request_finish (); + + try { + yield req.exec (out in_stream, out response_headers); + Json.Parser parser = yield Network.get_parser_from_inputstream_async (in_stream); + + Object[] to_add = {}; + Network.parse_array (parser, node => { + var e = Helper.Entity.from_json (node, accepts); + if (!(should_hide (e))) to_add += e; + }); + model.splice (model.get_n_items (), 0, to_add); + + if (response_headers != null) + get_pages (response_headers.get_one ("Link")); + + if (to_add.length == 0) + on_content_changed (); + on_request_finish (); + } catch (GLib.Error e) { + if (e is GLib.IOError.CANCELLED) { + debug ("Message is cancelled."); + } else { + on_error (e.code, e.message); + } + } } public override void on_error (int32 code, string reason) { From 725bff13c8c018dc0da8e1a0a061639b0a480040 Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Tue, 24 Mar 2026 15:57:09 +0200 Subject: [PATCH 7/8] feat(Timeline): use vala's error type based catchers --- src/Views/Timeline.vala | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Views/Timeline.vala b/src/Views/Timeline.vala index 33d99b616..9da5e1617 100644 --- a/src/Views/Timeline.vala +++ b/src/Views/Timeline.vala @@ -245,12 +245,10 @@ public class Tuba.Views.Timeline : AccountHolder, Streamable, Views.ContentBase if (to_add.length == 0) on_content_changed (); on_request_finish (); + } catch (GLib.IOError.CANCELLED e) { + debug ("Message is cancelled."); } catch (GLib.Error e) { - if (e is GLib.IOError.CANCELLED) { - debug ("Message is cancelled."); - } else { - on_error (e.code, e.message); - } + on_error (e.code, e.message); } } From 654e49c068281b6f859ac43d68470269f240e32b Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Tue, 24 Mar 2026 15:57:35 +0200 Subject: [PATCH 8/8] feat: return inputstream instead of outing --- src/Services/Network/Network.vala | 8 ++++---- src/Services/Network/RequestV2.vala | 4 ++-- src/Views/Timeline.vala | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Services/Network/Network.vala b/src/Services/Network/Network.vala index a289977eb..84029eabd 100644 --- a/src/Services/Network/Network.vala +++ b/src/Services/Network/Network.vala @@ -105,19 +105,19 @@ public class Tuba.Network : GLib.Object { }); } - public async void queue_v2 ( + public async GLib.InputStream queue_v2 ( owned Soup.Message msg, GLib.Cancellable? cancellable, - out GLib.InputStream in_stream, out Soup.MessageHeaders response_headers ) throws GLib.Error, Oopsie { requests_processing++; - in_stream = yield session.send_async (msg, 0, cancellable); + GLib.InputStream in_stream = yield session.send_async (msg, 0, cancellable); var status = msg.status_code; response_headers = msg.response_headers; - if (status >= 200 && status < 300) return; + if (status >= 200 && status < 300) + return in_stream; unowned string error_msg = msg.reason_phrase; try { diff --git a/src/Services/Network/RequestV2.vala b/src/Services/Network/RequestV2.vala index d3680ea3f..2b0ffcade 100644 --- a/src/Services/Network/RequestV2.vala +++ b/src/Services/Network/RequestV2.vala @@ -100,7 +100,7 @@ public class Tuba.RequestV2 : GLib.Object { set_body ("application/json", new Bytes.take (generator.to_data (null).data)); } - public async void exec (out GLib.InputStream in_stream, out Soup.MessageHeaders response_headers) throws GLib.Error, Oopsie { + public async GLib.InputStream exec (out Soup.MessageHeaders response_headers) throws GLib.Error, Oopsie { if (this.cancellable != null) this.cancellable.cancel (); this.cancellable = new GLib.Cancellable (); @@ -131,7 +131,7 @@ public class Tuba.RequestV2 : GLib.Object { if (this.content_type != null && this.body_bytes != null) message.set_request_body_from_bytes (this.content_type, this.body_bytes); - yield network.queue_v2 (message, this.cancellable, out in_stream, out response_headers); + return yield network.queue_v2 (message, this.cancellable, out response_headers); // TODO: ensure body_bytes = ctx = null... } diff --git a/src/Views/Timeline.vala b/src/Views/Timeline.vala index 9da5e1617..7d503fb46 100644 --- a/src/Views/Timeline.vala +++ b/src/Views/Timeline.vala @@ -229,7 +229,7 @@ public class Tuba.Views.Timeline : AccountHolder, Streamable, Views.ContentBase Soup.MessageHeaders response_headers; try { - yield req.exec (out in_stream, out response_headers); + in_stream = yield req.exec (out response_headers); Json.Parser parser = yield Network.get_parser_from_inputstream_async (in_stream); Object[] to_add = {};