Refactor entity resolving

This commit is contained in:
Bleak Grey 2020-08-01 18:40:56 +03:00
parent 6860ed28c2
commit 9ca9720f60
16 changed files with 406 additions and 256 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
using Gee;
public class Tootle.API.SearchResults : Entity {
public ArrayList<API.Account> accounts { get; set; }
public ArrayList<API.Status> statuses { get; set; }
public ArrayList<API.Tag> 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));
}
}

View File

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

View File

@ -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 (@"<a href=\"$(accounts.active.instance)/tags/$encoded\">#$name</a>");
w.use_markup = true;
w.halign = Align.START;
w.margin = 8;
w.show ();
return w;
}
}

View File

@ -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<API.Notification> cached_notifications { get; set; default = new ArrayList<API.Notification> (); }
public int64 last_seen_notification { get; set; default = 0; }
public bool has_unread_notifications { get; set; default = false; }
public ArrayList<API.Notification> cached_notifications { get; set; default = new ArrayList<API.Notification> (); }
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 ();
}
}
}

View File

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

View File

@ -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 (@"<span weight='bold' size='medium'>$name</span>");
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 (@"<span weight='bold' size='medium'>$name</span>");
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 (@"<a href=\"$(accounts.active.instance)/tags/$encoded\">#$name</a>");
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);
}
});
}
}

View File

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

View File

@ -3,109 +3,85 @@ using Gee;
public class Tootle.Widgets.RichLabel : Label {
public weak ArrayList<API.Mention>? mentions;
public weak ArrayList<API.Mention>? 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 ("&nbsp;", " ")
.replace ("'", "&apos;");
}
public static string escape_entities (string content) {
return content
.replace ("&nbsp;", " ")
.replace ("'", "&apos;");
}
public static string restore_entities (string content) {
return content
.replace ("&amp;", "&")
.replace ("&lt;", "<")
.replace ("&gt;", ">")
.replace ("&apos;", "'")
.replace ("&quot;", "\"");
}
public static string restore_entities (string content) {
return content
.replace ("&amp;", "&")
.replace ("&lt;", "<")
.replace ("&gt;", ">")
.replace ("&apos;", "'")
.replace ("&quot;", "\"");
}
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;
}
}

View File

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

View File

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

112
unsaved file 1 Normal file
View File

@ -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 (@"<span weight='bold' size='medium'>$name</span>");
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 (@"<a href=\"$(accounts.active.instance)/tags/$encoded\">#$name</a>");
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 ();
}
}