diff --git a/meson.build b/meson.build index 566ed72..13462b8 100644 --- a/meson.build +++ b/meson.build @@ -70,6 +70,7 @@ executable( 'src/API/Attachment.vala', 'src/API/Conversation.vala', 'src/API/List.vala', + 'src/API/SearchResults.vala', 'src/API/Entity.vala', 'src/Widgets/Widgetizable.vala', 'src/Widgets/Avatar.vala', diff --git a/src/API/Account.vala b/src/API/Account.vala index dc62e46..5db23a2 100644 --- a/src/API/Account.vala +++ b/src/API/Account.vala @@ -37,11 +37,30 @@ public class Tootle.API.Account : Entity, Widgetizable { return id == accounts.active.id; } + public override bool is_local (InstanceAccount account) { + return account.short_instance in url; + } + public override Gtk.Widget to_widget () { var status = new API.Status.from_account (this); return new Widgets.Status (status); } + public override void open () { + var view = new Views.Profile (this); + window.open_view (view); + } + + public override void resolve_open (InstanceAccount account) { + if (is_local (account)) + open (); + else { + account.resolve.begin (url, (obj, res) => { + account.resolve.end (res).open (); + }); + } + } + public Request get_relationship () { return new Request.GET ("/api/v1/accounts/relationships") .with_account (accounts.active) diff --git a/src/API/Conversation.vala b/src/API/Conversation.vala index f85b7ad..8230560 100644 --- a/src/API/Conversation.vala +++ b/src/API/Conversation.vala @@ -1,4 +1,4 @@ -public class Tootle.API.Conversation : Entity, Widgetizable { +public class Tootle.API.Conversation : Entity, Widgetizable { public string id { get; construct set; } public bool unread { get; set; default = false; } diff --git a/src/API/Entity.vala b/src/API/Entity.vala index a5f54cc..83c75c7 100644 --- a/src/API/Entity.vala +++ b/src/API/Entity.vala @@ -4,6 +4,10 @@ public class Tootle.Entity : GLib.Object, Widgetizable, Json.Serializable { public static string[] ignore_props = {"formal", "handle", "short-instance", "has-spoiler"}; + public virtual bool is_local (InstanceAccount account) { + return true; + } + public new ParamSpec[] list_properties () { ParamSpec[] specs = {}; foreach (ParamSpec spec in get_class ().list_properties ()) { @@ -13,7 +17,6 @@ public class Tootle.Entity : GLib.Object, Widgetizable, Json.Serializable { return specs; } - public void patch (GLib.Object with) { var props = with.get_class ().list_properties (); foreach (var prop in props) { @@ -86,6 +89,15 @@ public class Tootle.Entity : GLib.Object, Widgetizable, Json.Serializable { case "fields": contains = typeof (API.AccountField); break; + case "accounts": + contains = typeof (API.Account); + break; + case "statuses": + contains = typeof (API.Status); + break; + case "hashtags": + contains = typeof (API.Tag); + break; default: contains = typeof (Entity); break; diff --git a/src/API/Mention.vala b/src/API/Mention.vala index a84ddd3..f08f337 100644 --- a/src/API/Mention.vala +++ b/src/API/Mention.vala @@ -1,4 +1,4 @@ -public class Tootle.API.Mention : Entity { +public class Tootle.API.Mention : Entity, Widgetizable { public string id { get; construct set; } public string username { get; construct set; } @@ -14,4 +14,8 @@ public class Tootle.API.Mention : Entity { ); } + public override void open () { + Views.Profile.open_from_id (id); + } + } diff --git a/src/API/SearchResults.vala b/src/API/SearchResults.vala new file mode 100644 index 0000000..93a3230 --- /dev/null +++ b/src/API/SearchResults.vala @@ -0,0 +1,34 @@ +using Gee; + +public class Tootle.API.SearchResults : Entity { + + public ArrayList accounts { get; set; } + public ArrayList statuses { get; set; } + public ArrayList hashtags { get; set; } + + public static SearchResults from (Json.Node node) throws Error { + return Entity.from_json (typeof (SearchResults), node) as SearchResults; + } + + public Entity first () throws Error { + if (accounts.size > 0) + return accounts[0]; + else if (statuses.size > 0) + return statuses[0]; + else if (hashtags.size > 0) + return hashtags[0]; + else + throw new Oopsie.INTERNAL (_("Search returned no results")); + } + + public static async SearchResults request (string q, InstanceAccount account) throws Error { + var req = new Request.GET ("/api/v2/search") + .with_account (account) + .with_param ("resolve", "true") + .with_param ("q", Soup.URI.encode (q, null)); + yield req.await (); + + return from (network.parse_node (req)); + } + +} diff --git a/src/API/Status.vala b/src/API/Status.vala index 50a9b66..7cb0b62 100644 --- a/src/API/Status.vala +++ b/src/API/Status.vala @@ -77,6 +77,11 @@ public class Tootle.API.Status : Entity, Widgetizable { return new Widgets.Status (this); } + public override void open () { + var view = new Views.ExpandedStatus (formal); + window.open_view (view); + } + public bool is_owned (){ return formal.account.id == accounts.active.id; } diff --git a/src/API/Tag.vala b/src/API/Tag.vala index b6d2d49..346120e 100644 --- a/src/API/Tag.vala +++ b/src/API/Tag.vala @@ -1,4 +1,6 @@ -public class Tootle.API.Tag : Entity { +using Gtk; + +public class Tootle.API.Tag : Entity, Widgetizable { public string name { get; set; } public string url { get; set; } @@ -7,4 +9,14 @@ public class Tootle.API.Tag : Entity { return Entity.from_json (typeof (API.Tag), node) as API.Tag; } + public override Widget to_widget () { + var encoded = Soup.URI.encode (name, null); + var w = new Widgets.RichLabel (@"#$name"); + w.use_markup = true; + w.halign = Align.START; + w.margin = 8; + w.show (); + return w; + } + } diff --git a/src/InstanceAccount.vala b/src/InstanceAccount.vala index aaa22cb..8e8fac1 100644 --- a/src/InstanceAccount.vala +++ b/src/InstanceAccount.vala @@ -3,86 +3,89 @@ using Gee; public class Tootle.InstanceAccount : API.Account, IStreamListener { - public string instance { get; set; } - public string client_id { get; set; } - public string client_secret { get; set; } - public string access_token { get; set; } + public string instance { get; set; } + public string client_id { get; set; } + public string client_secret { get; set; } + public string access_token { get; set; } - public int64 last_seen_notification { get; set; default = 0; } - public bool has_unread_notifications { get; set; default = false; } - public ArrayList cached_notifications { get; set; default = new ArrayList (); } + public int64 last_seen_notification { get; set; default = 0; } + public bool has_unread_notifications { get; set; default = false; } + public ArrayList cached_notifications { get; set; default = new ArrayList (); } protected string? stream; - public new string handle { - owned get { return @"@$username@$short_instance"; } - } - public string short_instance { - owned get { - return instance - .replace ("https://", "") - .replace ("/",""); - } - } + public new string handle { + owned get { return @"@$username@$short_instance"; } + } + public string short_instance { + owned get { + return instance + .replace ("https://", "") + .replace ("/",""); + } + } public static InstanceAccount from (Json.Node node) throws Error { return Entity.from_json (typeof (InstanceAccount), node) as InstanceAccount; } - public InstanceAccount () { - on_notification.connect (show_notification); - } + public InstanceAccount () { + on_notification.connect (show_notification); + } ~InstanceAccount () { unsubscribe (); } - public InstanceAccount.empty (string instance){ - Object ( - id: "", - instance: instance - ); - } + public InstanceAccount.empty (string instance){ + Object (id: "", instance: instance); + } - public InstanceAccount.from_account (API.Account account) { - Object ( - id: account.id - ); - patch (account); - } + public InstanceAccount.from_account (API.Account account) { + Object (id: account.id); + patch (account); + } - public bool is_current () { - return accounts.active.access_token == access_token; - } + public bool is_current () { + return accounts.active.access_token == access_token; + } - public string get_stream_url () { - return @"$instance/api/v1/streaming/?stream=user&access_token=$access_token"; - } + public string get_stream_url () { + return @"$instance/api/v1/streaming/?stream=user&access_token=$access_token"; + } - public void subscribe () { - streams.subscribe (get_stream_url (), this, out stream); - } + public void subscribe () { + streams.subscribe (get_stream_url (), this, out stream); + } - public void unsubscribe () { - streams.unsubscribe (stream, this); - } + public void unsubscribe () { + streams.unsubscribe (stream, this); + } - protected void show_notification (API.Notification obj) { - var title = Html.remove_tags (obj.kind.get_desc (obj.account)); - var notification = new GLib.Notification (title); - if (obj.status != null) { - var body = ""; - body += short_instance; - body += "\n"; - body += Html.remove_tags (obj.status.content); - notification.set_body (body); - } + public async Entity resolve (string url) throws Error { + message (@"Resolving URL: \"$url\"..."); + var results = yield API.SearchResults.request (url, this); + var entity = results.first (); + message (@"Found $(entity.get_class ().get_name ())"); + return entity; + } + + void show_notification (API.Notification obj) { + var title = Html.remove_tags (obj.kind.get_desc (obj.account)); + var notification = new GLib.Notification (title); + if (obj.status != null) { + var body = ""; + body += short_instance; + body += "\n"; + body += Html.remove_tags (obj.status.content); + notification.set_body (body); + } app.send_notification (app.application_id + ":" + obj.id.to_string (), notification); - if (obj.kind == API.NotificationType.WATCHLIST) { - cached_notifications.add (obj); - accounts.save (); - } - } + if (obj.kind == API.NotificationType.WATCHLIST) { + cached_notifications.add (obj); + accounts.save (); + } + } } diff --git a/src/Views/Profile.vala b/src/Views/Profile.vala index 68dccd5..f205cc1 100644 --- a/src/Views/Profile.vala +++ b/src/Views/Profile.vala @@ -142,10 +142,8 @@ public class Tootle.Views.Profile : Views.Timeline { return req; } - public static void open_from_id (string id){ - var url = @"$(accounts.active.instance)/api/v1/accounts/$id"; - var msg = new Soup.Message ("GET", url); - msg.priority = Soup.MessagePriority.HIGH; + public static void open_from_id (string id) { + var msg = new Soup.Message ("GET", @"$(accounts.active.instance)/api/v1/accounts/$id"); network.queue (msg, (sess, mess) => { var node = network.parse_node (mess); var acc = API.Account.from (node); diff --git a/src/Views/Search.vala b/src/Views/Search.vala index 5b7d82d..8a951eb 100644 --- a/src/Views/Search.vala +++ b/src/Views/Search.vala @@ -2,111 +2,82 @@ using Gtk; public class Tootle.Views.Search : Views.Base { - string query = ""; - SearchBar bar; - SearchEntry entry; + string query = ""; + SearchBar bar; + SearchEntry entry; - construct { - label = _("Search"); + construct { + label = _("Search"); - bar = new SearchBar (); - bar.search_mode_enabled = true; - bar.show (); - pack_start (bar, false, false, 0); + bar = new SearchBar (); + bar.search_mode_enabled = true; + bar.show (); + pack_start (bar, false, false, 0); - entry = new SearchEntry (); - entry.width_chars = 25; - entry.text = query; - entry.show (); - bar.add (entry); - bar.connect_entry (entry); + entry = new SearchEntry (); + entry.width_chars = 25; + entry.text = query; + entry.show (); + bar.add (entry); + bar.connect_entry (entry); - entry.activate.connect (() => request ()); - entry.icon_press.connect (() => request ()); - entry.grab_focus_without_selecting (); - status_button.clicked.connect (request); + entry.activate.connect (() => request ()); + entry.icon_press.connect (() => request ()); + entry.grab_focus_without_selecting (); + status_button.clicked.connect (request); - request (); - } + request (); + } - void append_account (API.Account acc) { - var status = new API.Status.from_account (acc); - var w = new Widgets.Status (status); - content_list.insert (w, -1); - on_content_changed (); - } + bool append (owned Entity entity) { + var w = entity.to_widget (); + content_list.insert (w, -1); + return true; + } - void append_status (API.Status status) { - var w = new Widgets.Status (status); - content_list.insert (w, -1); - on_content_changed (); - } + void append_header (string name) { + var w = new Label (@"$name"); + w.halign = Align.START; + w.margin = 8; + w.use_markup = true; + w.show (); + content_list.insert (w, -1); + } - void append_header (string name) { - var w = new Label (@"$name"); - w.halign = Align.START; - w.margin = 8; - w.use_markup = true; - w.show (); - content_list.insert (w, -1); - on_content_changed (); - } + void request () { + query = entry.text.chug ().chomp (); + if (query == "") { + clear (); + return; + } - void append_hashtag (string name) { - var encoded = Soup.URI.encode (name, null); - var w = new Widgets.RichLabel (@"#$name"); - w.use_markup = true; - w.halign = Align.START; - w.margin = 8; - w.show (); - content_list.insert (w, -1); - } + clear (); + status_message = STATUS_LOADING; + API.SearchResults.request.begin (query, accounts.active, (obj, res) => { + try { + var results = API.SearchResults.request.end (res); - void request () { - query = entry.text; - if (query == "") { - clear (); - return; - } + if (!results.accounts.is_empty) { + append_header (_("People")); + results.accounts.@foreach (append); + } - status_message = STATUS_LOADING; - new Request.GET ("/api/v2/search") - .with_account (accounts.active) - .with_param ("resolve", "true") - .with_param ("q", Soup.URI.encode (query, null)) - .then ((sess, msg) => { - var root = network.parse (msg); - var accounts = root.get_array_member ("accounts"); - var statuses = root.get_array_member ("statuses"); - var hashtags = root.get_array_member ("hashtags"); + if (!results.statuses.is_empty) { + append_header (_("Posts")); + results.statuses.@foreach (append); + } - clear (); + if (!results.hashtags.is_empty) { + append_header (_("Hashtags")); + results.hashtags.@foreach (append); + } - if (hashtags.get_length () > 0) { - append_header (_("Hashtags")); - hashtags.foreach_element ((array, i, node) => { - append_hashtag (node.get_object ().get_string_member ("name")); - }); - } - - if (accounts.get_length () > 0) { - append_header (_("Accounts")); - accounts.foreach_element ((array, i, node) => { - var acc = API.Account.from (node); - append_account (acc); - }); - } - - if (statuses.get_length () > 0) { - append_header (_("Statuses")); - statuses.foreach_element ((array, i, node) => { - var status = API.Status.from (node); - append_status (status); - }); - } - }) - .on_error (on_error) - .exec (); - } + on_content_changed (); + } + catch (Error e) { + on_error (-1, e.message); + } + }); + } } diff --git a/src/Widgets/AccountsButton.vala b/src/Widgets/AccountsButton.vala index a28936f..b23e964 100644 --- a/src/Widgets/AccountsButton.vala +++ b/src/Widgets/AccountsButton.vala @@ -49,8 +49,8 @@ public class Tootle.Widgets.AccountsButton : Gtk.MenuButton, IAccountListener { [GtkCallback] void open_profile () { - Views.Profile.open_from_id (account.id); button.active = false; + account.resolve_open (accounts.active); } } diff --git a/src/Widgets/RichLabel.vala b/src/Widgets/RichLabel.vala index ace90b1..8650411 100644 --- a/src/Widgets/RichLabel.vala +++ b/src/Widgets/RichLabel.vala @@ -3,109 +3,85 @@ using Gee; public class Tootle.Widgets.RichLabel : Label { - public weak ArrayList? mentions; + public weak ArrayList? mentions; - public string text { - get { - return this.label; - } - set { - this.label = escape_entities (Html.simplify (value)); - } - } + public string text { + get { + return this.label; + } + set { + this.label = escape_entities (Html.simplify (value)); + } + } construct { use_markup = true; xalign = 0; - wrap_mode = Pango.WrapMode.WORD_CHAR; - justify = Justification.LEFT; - single_line_mode = false; - set_line_wrap (true); + wrap_mode = Pango.WrapMode.WORD_CHAR; + justify = Justification.LEFT; + single_line_mode = false; + set_line_wrap (true); activate_link.connect (open_link); } - public RichLabel (string text) { - set_label (text); - } + public RichLabel (string text) { + set_label (text); + } - public static string escape_entities (string content) { - return content - .replace (" ", " ") - .replace ("'", "'"); - } + public static string escape_entities (string content) { + return content + .replace (" ", " ") + .replace ("'", "'"); + } - public static string restore_entities (string content) { - return content - .replace ("&", "&") - .replace ("<", "<") - .replace (">", ">") - .replace ("'", "'") - .replace (""", "\""); - } + public static string restore_entities (string content) { + return content + .replace ("&", "&") + .replace ("<", "<") + .replace (">", ">") + .replace ("'", "'") + .replace (""", "\""); + } - public bool open_link (string url) { - if ("tootle://" in url) - return false; + public bool open_link (string url) { + if ("tootle://" in url) + return false; - if (mentions != null){ - mentions.@foreach (mention => { - if (url == mention.url) - Views.Profile.open_from_id (mention.id); - return true; - }); - } + if (mentions != null){ + mentions.@foreach (mention => { + if (url == mention.url) + mention.open (); + return true; + }); + } - if ("/tags/" in url) { - var encoded = url.split("/tags/")[1]; - var hashtag = Soup.URI.decode (encoded); - window.open_view (new Views.Hashtag (hashtag)); - return true; - } + if ("/tags/" in url) { + var encoded = url.split ("/tags/")[1]; + var tag = Soup.URI.decode (encoded); + window.open_view (new Views.Hashtag (tag)); + return true; + } - if ("@" in url || "tags" in url) { - new Request.GET ("/api/v2/search") - .with_account (accounts.active) - .with_param ("resolve", "true") - .with_param ("q", Soup.URI.encode (url, null)) - .then ((sess, mess) => { - var root = network.parse (mess); - var accounts = root.get_array_member ("accounts"); - var statuses = root.get_array_member ("statuses"); - var hashtags = root.get_array_member ("hashtags"); + var resolve = "@" in url; + var resolved = false; + if (resolve) { + accounts.active.resolve.begin (url, (obj, res) => { + try { + accounts.active.resolve.end (res).open (); + resolved = true; + } + catch (Error e) { + warning (@"Failed to resolve URL \"$url\":"); + warning (e.message); + } + }); + } - if (accounts.get_length () > 0) { - var node = accounts.get_element (0); - var obj = API.Account.from (node); - window.open_view (new Views.Profile (obj)); - } - else if (statuses.get_length () > 0) { - var node = accounts.get_element (0); - var obj = API.Status.from (node); - window.open_view (new Views.ExpandedStatus (obj)); - } - else if (hashtags.get_length () > 0) { - var node = accounts.get_element (0); - var obj = API.Tag.from (node); - window.open_view (new Views.Hashtag (obj.name)); - } - else { - Desktop.open_uri (url); - } - }) - .on_error ((status, reason) => open_link_fallback (url, reason)) - .exec (); - } - else { - Desktop.open_uri (url); - } - return true; - } + if (!resolved) + Desktop.open_uri (url); + + return true; + } - public bool open_link_fallback (string url, string reason) { - warning (@"Can't resolve url: $url"); - warning (@"Reason: $reason"); - Desktop.open_uri (url); - return true; - } } diff --git a/src/Widgets/Status.vala b/src/Widgets/Status.vala index a1e3cf8..208850d 100644 --- a/src/Widgets/Status.vala +++ b/src/Widgets/Status.vala @@ -92,11 +92,8 @@ public class Tootle.Widgets.Status : ListBoxRow { public virtual signal void open () { if (status.id == "") on_avatar_clicked (); - else { - var formal = status.formal; - var view = new Views.ExpandedStatus (formal); - window.open_view (view); - } + else + status.open (); } construct { @@ -183,8 +180,7 @@ public class Tootle.Widgets.Status : ListBoxRow { [GtkCallback] public void on_avatar_clicked () { - var view = new Views.Profile (status.formal.account); - window.open_view (view); + status.formal.account.open (); } protected void open_menu () { diff --git a/src/Widgets/Widgetizable.vala b/src/Widgets/Widgetizable.vala index 0b71065..0c149fe 100644 --- a/src/Widgets/Widgetizable.vala +++ b/src/Widgets/Widgetizable.vala @@ -4,4 +4,11 @@ public interface Tootle.Widgetizable : GLib.Object { throw new Tootle.Oopsie.INTERNAL ("Widgetizable didn't provide a Widget!"); } + public virtual void open () { + warning ("Widgetizable didn't provide a way to open it!"); + } + public virtual void resolve_open (InstanceAccount account) { + this.open (); + } + } diff --git a/unsaved file 1 b/unsaved file 1 new file mode 100644 index 0000000..b406f3f --- /dev/null +++ b/unsaved file 1 @@ -0,0 +1,112 @@ +using Gtk; + +public class Tootle.Views.Search : Views.Base { + + string query = ""; + SearchBar bar; + SearchEntry entry; + + construct { + label = _("Search"); + + bar = new SearchBar (); + bar.search_mode_enabled = true; + bar.show (); + pack_start (bar, false, false, 0); + + entry = new SearchEntry (); + entry.width_chars = 25; + entry.text = query; + entry.show (); + bar.add (entry); + bar.connect_entry (entry); + + entry.activate.connect (() => request ()); + entry.icon_press.connect (() => request ()); + entry.grab_focus_without_selecting (); + status_button.clicked.connect (request); + + request (); + } + + void append_account (API.Account acc) { + var status = new API.Status.from_account (acc); + var w = new Widgets.Status (status); + content_list.insert (w, -1); + on_content_changed (); + } + + void append_status (API.Status status) { + var w = new Widgets.Status (status); + content_list.insert (w, -1); + on_content_changed (); + } + + void append_header (string name) { + var w = new Label (@"$name"); + w.halign = Align.START; + w.margin = 8; + w.use_markup = true; + w.show (); + content_list.insert (w, -1); + on_content_changed (); + } + + void append_hashtag (string name) { + var encoded = Soup.URI.encode (name, null); + var w = new Widgets.RichLabel (@"#$name"); + w.use_markup = true; + w.halign = Align.START; + w.margin = 8; + w.show (); + content_list.insert (w, -1); + } + + void request () { + query = entry.text; + if (query == "") { + clear (); + return; + } + + status_message = STATUS_LOADING; + new Request.GET ("/api/v2/search") + .with_account (accounts.active) + .with_param ("resolve", "true") + .with_param ("q", Soup.URI.encode (query, null)) + .then ((sess, msg) => { + var root = network.parse (msg); + var accounts = root.get_array_member ("accounts"); + var statuses = root.get_array_member ("statuses"); + var hashtags = root.get_array_member ("hashtags"); + + clear (); + + if (hashtags.get_length () > 0) { + append_header (_("Hashtags")); + hashtags.foreach_element ((array, i, node) => { + append_hashtag (node.get_object ().get_string_member ("name")); + }); + } + + if (accounts.get_length () > 0) { + append_header (_("Accounts")); + accounts.foreach_element ((array, i, node) => { + var acc = API.Account.from (node); + append_account (acc); + }); + } + + if (statuses.get_length () > 0) { + append_header (_("Statuses")); + statuses.foreach_element ((array, i, node) => { + var status = API.Status.from (node); + append_status (status); + }); + } + }) + .on_error (on_error) + .exec (); + } + +}