From 46f591c23275331c19e5c8bb355842265fbaa018 Mon Sep 17 00:00:00 2001 From: Evangelos Paterakis Date: Thu, 17 Nov 2022 20:32:26 +0200 Subject: [PATCH] feat: partial emoji support --- data/app.css | 10 +- data/ui/views/profile_header.ui | 4 +- data/ui/widgets/status.ui | 12 +- meson.build | 3 + src/API/Account.vala | 18 +++ src/API/Emoji.vala | 4 + src/API/Entity.vala | 3 + src/API/Notification.vala | 3 +- src/Dialogs/NewAccount.vala | 2 +- src/Services/Accounts/InstanceAccount.vala | 3 +- src/Services/Accounts/Mastodon/Account.vala | 22 ++-- src/Views/Base.vala | 2 +- src/Views/Profile.vala | 11 +- src/Widgets/Emoji.vala | 21 ++++ src/Widgets/RichLabelContainer.vala | 130 ++++++++++++++++++++ src/Widgets/Status.vala | 13 +- 16 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 src/API/Emoji.vala create mode 100644 src/Widgets/Emoji.vala create mode 100644 src/Widgets/RichLabelContainer.vala diff --git a/data/app.css b/data/app.css index b69f384..edc11de 100644 --- a/data/app.css +++ b/data/app.css @@ -75,8 +75,14 @@ margin-bottom: 32px; } - - +.ttl-label-emoji-no-click:hover { + background: transparent; +} + +.ttl-label-emoji-no-click { + background: transparent; + padding: 0; +} /* Profile diff --git a/data/ui/views/profile_header.ui b/data/ui/views/profile_header.ui index ba28de2..14b3ed1 100644 --- a/data/ui/views/profile_header.ui +++ b/data/ui/views/profile_header.ui @@ -56,8 +56,8 @@ vertical 6 - - Unknown + + diff --git a/data/ui/widgets/status.ui b/data/ui/widgets/status.ui index 3574057..cba957e 100644 --- a/data/ui/widgets/status.ui +++ b/data/ui/widgets/status.ui @@ -27,9 +27,9 @@ - + 0 - end + 8 diff --git a/meson.build b/meson.build index d0fd6c4..c9367cc 100644 --- a/meson.build +++ b/meson.build @@ -48,6 +48,7 @@ sources = files( 'src/API/AccountField.vala', 'src/API/Attachment.vala', 'src/API/Conversation.vala', + 'src/API/Emoji.vala', 'src/API/Entity.vala', 'src/API/List.vala', 'src/API/Mention.vala', @@ -109,11 +110,13 @@ sources = files( 'src/Widgets/Avatar.vala', 'src/Widgets/Background.vala', 'src/Widgets/Conversation.vala', + 'src/Widgets/Emoji.vala', 'src/Widgets/LockableToggleButton.vala', 'src/Widgets/MarkupView.vala', 'src/Widgets/Notification.vala', 'src/Widgets/RelationshipButton.vala', 'src/Widgets/RichLabel.vala', + 'src/Widgets/RichLabelContainer.vala', 'src/Widgets/Status.vala', 'src/Widgets/StatusActionButton.vala', 'src/Widgets/Widgetizable.vala', diff --git a/src/API/Account.vala b/src/API/Account.vala index 4bb51cc..9f8a175 100644 --- a/src/API/Account.vala +++ b/src/API/Account.vala @@ -22,6 +22,7 @@ public class Tooth.API.Account : Entity, Widgetizable { public string avatar { get; set; } public string url { get; set; } public string created_at { get; set; } + public Gee.ArrayList? emojis { get; set; } public int64 followers_count { get; set; } public int64 following_count { get; set; } public int64 statuses_count { get; set; } @@ -38,6 +39,23 @@ public class Tooth.API.Account : Entity, Widgetizable { return uri.get_host (); } } + public Gee.HashMap? emojis_map { + owned get { + return gen_emojis_map(); + } + } + + private Gee.HashMap? gen_emojis_map () { + var res = new Gee.HashMap(); + if (emojis != null && emojis.size > 0) { + emojis.@foreach (e => { + res.set(e.shortcode, e.url); + return true; + }); + } + + return res; + } public static Account from (Json.Node node) throws Error { return Entity.from_json (typeof (API.Account), node) as API.Account; diff --git a/src/API/Emoji.vala b/src/API/Emoji.vala new file mode 100644 index 0000000..f4ba61d --- /dev/null +++ b/src/API/Emoji.vala @@ -0,0 +1,4 @@ +public class Tooth.API.Emoji : Entity { + public string shortcode { get; set; } + public string url { get; set; } +} diff --git a/src/API/Entity.vala b/src/API/Entity.vala index 258e5a1..9f2808b 100644 --- a/src/API/Entity.vala +++ b/src/API/Entity.vala @@ -78,6 +78,9 @@ public class Tooth.Entity : GLib.Object, Widgetizable, Json.Serializable { case "mentions": contains = typeof (API.Mention); break; + case "emojis": + contains = typeof (API.Emoji); + break; case "fields": contains = typeof (API.AccountField); break; diff --git a/src/API/Notification.vala b/src/API/Notification.vala index de80232..9806a76 100644 --- a/src/API/Notification.vala +++ b/src/API/Notification.vala @@ -12,7 +12,8 @@ public class Tooth.API.Notification : Entity, Widgetizable { // TODO: notification actions public virtual GLib.Notification to_toast (InstanceAccount issuer) { string descr; - issuer.describe_kind (kind, null, out descr, account); + string descr_url; + issuer.describe_kind (kind, null, out descr, account, out descr_url); var toast = new GLib.Notification ( HtmlUtils.remove_tags (descr) ); if (status != null) { diff --git a/src/Dialogs/NewAccount.vala b/src/Dialogs/NewAccount.vala index 862d98b..94abc22 100644 --- a/src/Dialogs/NewAccount.vala +++ b/src/Dialogs/NewAccount.vala @@ -20,7 +20,7 @@ public class Tooth.Dialogs.NewAccount: Adw.Window { [GtkChild] unowned Entry code_entry; [GtkChild] unowned Label code_entry_error; - [GtkChild] unowned Label code_label; + // [GtkChild] unowned Label code_label; [GtkChild] unowned Adw.StatusPage done_page; public NewAccount () { diff --git a/src/Services/Accounts/InstanceAccount.vala b/src/Services/Accounts/InstanceAccount.vala index a408e87..fba2636 100644 --- a/src/Services/Accounts/InstanceAccount.vala +++ b/src/Services/Accounts/InstanceAccount.vala @@ -117,9 +117,10 @@ public class Tooth.InstanceAccount : API.Account, Streamable { return entity; } - public virtual void describe_kind (string kind, out string? icon, out string? descr, API.Account account) { + public virtual void describe_kind (string kind, out string? icon, out string? descr, API.Account account, out string? descr_url) { icon = null; descr = null; + descr_url = null; } public virtual void register_known_places (GLib.ListStore places) {} diff --git a/src/Services/Accounts/Mastodon/Account.vala b/src/Services/Accounts/Mastodon/Account.vala index 19aae4a..dac3663 100644 --- a/src/Services/Accounts/Mastodon/Account.vala +++ b/src/Services/Accounts/Mastodon/Account.vala @@ -140,39 +140,47 @@ public class Tooth.Mastodon.Account : InstanceAccount { }); } - public override void describe_kind (string kind, out string? icon, out string? descr, API.Account account) { + public override void describe_kind (string kind, out string? icon, out string? descr, API.Account account, out string? descr_url) { switch (kind) { case KIND_MENTION: icon = "user-available-symbolic"; - descr = _("%s mentioned you").printf (account.url, account.display_name); + descr = _("%s mentioned you").printf (account.display_name); + descr_url = account.url; break; case KIND_REBLOG: icon = "media-playlist-repeat-symbolic"; - descr = _("%s boosted your status").printf (account.url, account.display_name); + descr = _("%s boosted your status").printf (account.display_name); + descr_url = account.url; break; case KIND_REMOTE_REBLOG: icon = "media-playlist-repeat-symbolic"; - descr = _("%s boosted").printf (account.url, account.display_name); + descr = _("%s boosted").printf (account.display_name); + descr_url = account.url; break; case KIND_FAVOURITE: icon = "starred-symbolic"; - descr = _("%s favorited your status").printf (account.url, account.display_name); + descr = _("%s favorited your status").printf (account.display_name); + descr_url = account.url; break; case KIND_FOLLOW: icon = "contact-new-symbolic"; - descr = _("%s now follows you").printf (account.url, account.display_name); + descr = _("%s now follows you").printf (account.display_name); + descr_url = account.url; break; case KIND_FOLLOW_REQUEST: icon = "contact-new-symbolic"; - descr = _("%s wants to follow you").printf (account.url, account.display_name); + descr = _("%s wants to follow you").printf (account.display_name); + descr_url = account.url; break; case KIND_POLL: icon = "emblem-default-symbolic"; descr = _("Poll results"); + descr_url = null; break; default: icon = null; descr = null; + descr_url = null; break; } } diff --git a/src/Views/Base.vala b/src/Views/Base.vala index 599126e..2803de4 100644 --- a/src/Views/Base.vala +++ b/src/Views/Base.vala @@ -62,7 +62,7 @@ public class Tooth.Views.Base : Box { protected virtual void build_actions () {} protected virtual void build_header () { - var title = new Adw.WindowTitle (null, null); + var title = new Adw.WindowTitle (label, ""); bind_property ("label", title, "title", BindingFlags.SYNC_CREATE); header.title_widget = title; } diff --git a/src/Views/Profile.vala b/src/Views/Profile.vala index 0321228..59ea263 100644 --- a/src/Views/Profile.vala +++ b/src/Views/Profile.vala @@ -40,13 +40,14 @@ public class Tooth.Views.Profile : Views.Timeline { [GtkChild] unowned Widgets.Background background; [GtkChild] unowned ListBox info; - [GtkChild] unowned Widgets.RichLabel display_name; + [GtkChild] unowned Widgets.RichLabelContainer display_name; [GtkChild] unowned Label handle; [GtkChild] unowned Widgets.Avatar avatar; [GtkChild] unowned Widgets.MarkupView note; public void bind (API.Account account) { - display_name.label = account.display_name; + // display_name.label = account.display_name; + display_name.set_label(account.display_name, null, account.emojis_map); handle.label = account.handle; avatar.account = account; note.content = account.note; @@ -236,7 +237,7 @@ public class Tooth.Views.Profile : Views.Timeline { } // TODO: RS badges - void on_rs_updated () { + // void on_rs_updated () { // var label = ""; // if (rs_button.sensitive = rs != null) { // if (rs.requested) @@ -258,8 +259,8 @@ public class Tooth.Views.Profile : Views.Timeline { // relationship.label = label; // relationship.visible = label != ""; - invalidate_actions (false); - } + // invalidate_actions (false); + // } public override Request append_params (Request req) { if (page_next == null && source == "statuses") { diff --git a/src/Widgets/Emoji.vala b/src/Widgets/Emoji.vala new file mode 100644 index 0000000..6b4619d --- /dev/null +++ b/src/Widgets/Emoji.vala @@ -0,0 +1,21 @@ +using Gtk; +using Gdk; + +public class Tooth.Widgets.Emoji : Adw.Bin { + + protected Image image; + + construct { + image = new Gtk.Image (); + child = image; + } + + + public Emoji (string emoji_url) { + image_cache.request_paintable (emoji_url, on_cache_response); + } + + void on_cache_response (bool is_loaded, owned Paintable? data) { + (child as Image).paintable = data; + } +} diff --git a/src/Widgets/RichLabelContainer.vala b/src/Widgets/RichLabelContainer.vala new file mode 100644 index 0000000..c839186 --- /dev/null +++ b/src/Widgets/RichLabelContainer.vala @@ -0,0 +1,130 @@ +using Gtk; +using Gee; + +public class Tooth.Widgets.RichLabelContainer : Adw.Bin { + + Button widget; + Box button_child; + string on_click_url = null; + public weak ArrayList? mentions; + + construct { + widget = new Button (); + button_child = new Box(Orientation.HORIZONTAL, 0); + + widget.child = button_child; + widget.halign = Align.START; + + child = widget; + widget.clicked.connect (on_click); + } + + public void set_label (string text, string? url, Gee.HashMap? emojis) { + if (text.contains(":") && emojis != null) { + string[] labelss = text.split (":"); + + // Whether the last item was an emoji + bool was_emoji = false; + // Whether its the first item + bool is_first = labelss[0].get_char() != ':'; + + // The last created label + Label? last_label = null; + foreach (unowned string str in labelss) { + // If str is an available emoji + if (emojis.has_key(str)) { + button_child.append(new Widgets.Emoji(emojis.get(str))); + was_emoji = true; + } else { + // The label + // if the last item was not an emoji + // and its not the first item, prefix + // it with a ":" + // This way if someone has a name that + // includes ":" (and its not an emoji) + // we re-create the original string + string txt = (!was_emoji && !is_first ? ":" : "") + str; + // If the last label was not an emoji + // append the new label to it + // instead of creating a new one + if (last_label != null && !was_emoji) { + last_label.label = last_label.label + txt; + } else { + var tmp_child = new Label ("") { + xalign = 0, + wrap = true, + wrap_mode = Pango.WrapMode.WORD_CHAR, + justify = Justification.LEFT, + single_line_mode = false, + use_markup = false + }; + tmp_child.label = txt; + button_child.append(tmp_child); + } + was_emoji = false; + } + is_first = false; + } + } else { + var tmp_child = new Label ("") { + xalign = 0, + wrap = true, + wrap_mode = Pango.WrapMode.WORD_CHAR, + justify = Justification.LEFT, + single_line_mode = false, + use_markup = true // allow markup + }; + tmp_child.label = text; + button_child.append(tmp_child); + } + // if there's no url + // make the button look + // like a label + if (url ==null) { + widget.add_css_class("ttl-label-emoji-no-click"); + } + + on_click_url = url; + } + + protected void on_click () { + if (on_click_url == null) return; + + if (mentions != null){ + mentions.@foreach (mention => { + if (on_click_url == mention.url) + mention.open (); + return true; + }); + } + + if ("/tags/" in on_click_url) { + var encoded = on_click_url.split ("/tags/")[1]; + var tag = Soup.URI.decode (encoded); + app.main_window.open_view (new Views.Hashtag (tag)); + return; + } + + if (should_resolve_url (on_click_url)) { + accounts.active.resolve.begin (on_click_url, (obj, res) => { + try { + accounts.active.resolve.end (res).open (); + } + catch (Error e) { + warning (@"Failed to resolve URL \"$on_click_url\":"); + warning (e.message); + Host.open_uri (on_click_url); + } + }); + } + else { + Host.open_uri (on_click_url); + } + + return; + } + + public static bool should_resolve_url (string url) { + return settings.aggressive_resolving || "@" in url || "user" in url; + } +} diff --git a/src/Widgets/Status.vala b/src/Widgets/Status.vala index bb92d86..2c2286c 100644 --- a/src/Widgets/Status.vala +++ b/src/Widgets/Status.vala @@ -31,11 +31,11 @@ public class Tooth.Widgets.Status : ListBoxRow { [GtkChild] protected unowned Grid grid; [GtkChild] protected unowned Image header_icon; - [GtkChild] protected unowned Widgets.RichLabel header_label; + [GtkChild] protected unowned Widgets.RichLabelContainer header_label; [GtkChild] public unowned Image thread_line; [GtkChild] public unowned Widgets.Avatar avatar; - [GtkChild] protected unowned Widgets.RichLabel name_label; + [GtkChild] protected unowned Widgets.RichLabelContainer name_label; [GtkChild] protected unowned Label handle_label; [GtkChild] protected unowned Box indicators; [GtkChild] protected unowned Label date_label; @@ -122,20 +122,23 @@ public class Tooth.Widgets.Status : ListBoxRow { protected virtual void change_kind () { string icon = null; string descr = null; - accounts.active.describe_kind (this.kind, out icon, out descr, this.kind_instigator); + string label_url = null; + accounts.active.describe_kind (this.kind, out icon, out descr, this.kind_instigator, out label_url); header_icon.visible = header_label.visible = (icon != null); if (icon == null) return; header_icon.icon_name = icon; - header_label.label = descr; + header_label.set_label(descr, label_url, this.kind_instigator.emojis_map); } protected virtual void bind () { // Content bind_property ("spoiler-text", spoiler_label, "label", BindingFlags.SYNC_CREATE); status.formal.bind_property ("content", content, "content", BindingFlags.SYNC_CREATE); - bind_property ("title_text", name_label, "label", BindingFlags.SYNC_CREATE); + // bind_property ("title_text", name_label, "label", BindingFlags.SYNC_CREATE); + // title_text + name_label.set_label(title_text, null, this.kind_instigator.emojis_map); bind_property ("subtitle_text", handle_label, "label", BindingFlags.SYNC_CREATE); bind_property ("date", date_label, "label", BindingFlags.SYNC_CREATE); status.formal.bind_property ("pinned", pin_indicator, "visible", BindingFlags.SYNC_CREATE);