From 02f168e19d2e90267a83c27d643dca74eb5ff049 Mon Sep 17 00:00:00 2001 From: Bleak Grey Date: Tue, 2 Feb 2021 12:16:59 +0300 Subject: [PATCH] Introduce MarkupView widget (#264) Co-authored-by: Bleak Grey --- README.md | 1 + data/app.css | 7 ++ data/ui/views/base.ui | 1 + data/ui/views/profile_header.ui | 4 +- data/ui/widgets/profile_field_row.ui | 1 + data/ui/widgets/status.ui | 14 +-- meson.build | 3 + src/API/Attachment.vala | 2 +- src/API/Status.vala | 4 +- src/Dialogs/Compose.vala | 6 +- src/Html.vala | 10 +- src/InstanceAccount.vala | 4 +- src/Views/Profile.vala | 6 +- src/Widgets/Conversation.vala | 2 +- src/Widgets/MarkupPolicy.vala | 27 +++++ src/Widgets/MarkupView.vala | 174 +++++++++++++++++++++++++++ src/Widgets/RichLabel.vala | 15 ++- src/Widgets/Status.vala | 38 +++--- 18 files changed, 273 insertions(+), 46 deletions(-) create mode 100644 src/Widgets/MarkupPolicy.vala create mode 100644 src/Widgets/MarkupView.vala diff --git a/README.md b/README.md index bd28983..ab7fe2a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Simple [Mastodon](https://github.com/tootsuite/mastodon) client for Linux valac | 0.48 libglib-2.0-dev | 2.30.0 libjson-glib-dev | 1.4.4 + libxml2-dev | 2.9.10 libgee-0.8-dev | 0.8.5 libsoup2.4-dev | 2.64 libgtk-3-dev | 3.22.0 diff --git a/data/app.css b/data/app.css index 1b0674b..d70253e 100644 --- a/data/app.css +++ b/data/app.css @@ -58,3 +58,10 @@ .ttl-large-body { font-size: 110%; } + +.ttl-code { + font-family: monospace; + padding: 12px; + background: rgba(150,150,150,.1); + border-radius: 6px; +} diff --git a/data/ui/views/base.ui b/data/ui/views/base.ui index d2d055c..fdbf889 100644 --- a/data/ui/views/base.ui +++ b/data/ui/views/base.ui @@ -67,6 +67,7 @@ False False crossfade + True True diff --git a/data/ui/views/profile_header.ui b/data/ui/views/profile_header.ui index 3abf2a0..0dc0e3c 100644 --- a/data/ui/views/profile_header.ui +++ b/data/ui/views/profile_header.ui @@ -100,16 +100,14 @@ False False - + True - True False 8 8 8 8 True - 25 diff --git a/data/ui/widgets/profile_field_row.ui b/data/ui/widgets/profile_field_row.ui index 7ad7953..9414ddd 100644 --- a/data/ui/widgets/profile_field_row.ui +++ b/data/ui/widgets/profile_field_row.ui @@ -48,6 +48,7 @@ True word-char 0 + allow 2 diff --git a/data/ui/widgets/status.ui b/data/ui/widgets/status.ui index ec59fb2..53d0f84 100644 --- a/data/ui/widgets/status.ui +++ b/data/ui/widgets/status.ui @@ -57,6 +57,7 @@ 0 8 false + trust @@ -91,13 +92,14 @@ - + True False Handle 0.5 end True + 0 @@ -143,11 +145,12 @@ - + True False Yesterday 0.5 + 0 @@ -241,15 +244,10 @@ vertical 8 - + True False True - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. - True - word-char - 15 - 0 False diff --git a/meson.build b/meson.build index 34e6ed2..6bc7101 100644 --- a/meson.build +++ b/meson.build @@ -96,6 +96,8 @@ executable( 'src/Widgets/Attachment/Slot.vala', 'src/Widgets/Attachment/Picture.vala', 'src/Widgets/AdaptiveButton.vala', + 'src/Widgets/MarkupPolicy.vala', + 'src/Widgets/MarkupView.vala', 'src/Dialogs/ISavedWindow.vala', 'src/Dialogs/NewAccount.vala', 'src/Dialogs/MainWindow.vala', @@ -126,6 +128,7 @@ executable( dependency('gee-0.8', version: '>=0.8.5'), dependency('libsoup-2.4'), dependency('json-glib-1.0', version: '>=1.4.4'), + dependency('libxml-2.0'), libhandy_dep, ], install: true, diff --git a/src/API/Attachment.vala b/src/API/Attachment.vala index 7abb71a..5c66e79 100644 --- a/src/API/Attachment.vala +++ b/src/API/Attachment.vala @@ -63,7 +63,7 @@ public class Tootle.API.Attachment : Entity { var descr_param = ""; if (descr != null && descr.replace (" ", "") != "") { - descr_param = "?description=" + Html.uri_encode (descr); + descr_param = "?description=" + HtmlUtils.uri_encode (descr); } var buffer = new Soup.Buffer.take (contents); diff --git a/src/API/Status.vala b/src/API/Status.vala index 7332b4a..bb8799d 100644 --- a/src/API/Status.vala +++ b/src/API/Status.vala @@ -68,9 +68,9 @@ public class Tootle.API.Status : Entity, Widgetizable { if (account.note == "") content = ""; else if ("\n" in account.note) - content = Html.remove_tags (account.note.split ("\n")[0]); + content = account.note.split ("\n")[0]; else - content = Html.remove_tags (account.note); + content = account.note; } public override Gtk.Widget to_widget () { diff --git a/src/Dialogs/Compose.vala b/src/Dialogs/Compose.vala index 69098fc..cc31b52 100644 --- a/src/Dialogs/Compose.vala +++ b/src/Dialogs/Compose.vala @@ -110,7 +110,7 @@ public class Tootle.Dialogs.Compose : Hdy.Window { cw.text = status.spoiler_text; cw_button.active = true; } - content.buffer.text = Html.remove_tags (status.content); + content.buffer.text = HtmlUtils.remove_tags (status.content); validate (); set_media_mode (status.has_media ()); @@ -291,11 +291,11 @@ public class Tootle.Dialogs.Compose : Hdy.Window { var req = new Request.POST (@"/api/v1/statuses?$media_param") .with_account (accounts.active) .with_param ("visibility", visibility_popover.selected.to_string ()) - .with_param ("status", Html.uri_encode (status.content)); + .with_param ("status", HtmlUtils.uri_encode (status.content)); if (cw_button.active) { req.with_param ("sensitive", "true"); - req.with_param ("spoiler_text", Html.uri_encode (cw.text)); + req.with_param ("spoiler_text", HtmlUtils.uri_encode (cw.text)); } if (status.in_reply_to_id != null) req.with_param ("in_reply_to_id", status.in_reply_to_id); diff --git a/src/Html.vala b/src/Html.vala index 329fc8f..71db98d 100644 --- a/src/Html.vala +++ b/src/Html.vala @@ -1,10 +1,12 @@ -public class Tootle.Html { +public class Tootle.HtmlUtils { public const string FALLBACK_TEXT = _("[ There was an error parsing this text :c ]"); public static string remove_tags (string content) { try { + //TODO: remove this when simplify() uses the HTML parsing class var fixed_paragraphs = simplify (content); + var all_tags = new Regex ("<(.|\n)*?>", RegexCompileFlags.CASELESS); return Widgets.RichLabel.restore_entities (all_tags.replace (fixed_paragraphs, -1, 0, "")); } @@ -14,6 +16,8 @@ public class Tootle.Html { } } + //TODO: Perhaps this should use the HTML parser class + // since we depend on it anyway public static string simplify (string str) { try { var divided = str @@ -32,8 +36,8 @@ public class Tootle.Html { return simplified; } catch (Error e) { - warning (e.message); - return FALLBACK_TEXT; + warning (@"Can't simplify string \"$str\":\n$(e.message)"); + return remove_tags (str); } } diff --git a/src/InstanceAccount.vala b/src/InstanceAccount.vala index 61c2dfb..b7d748b 100644 --- a/src/InstanceAccount.vala +++ b/src/InstanceAccount.vala @@ -63,13 +63,13 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener { } void show_notification (API.Notification obj) { - var title = Html.remove_tags (obj.kind.get_desc (obj.account)); + var title = HtmlUtils.remove_tags (obj.kind.get_desc (obj.account)); var notification = new GLib.Notification (title); if (obj.status != null) { var body = ""; body += domain; body += "\n"; - body += Html.remove_tags (obj.status.content); + body += HtmlUtils.remove_tags (obj.status.content); notification.set_body (body); } diff --git a/src/Views/Profile.vala b/src/Views/Profile.vala index 51ec9e4..13bb4b5 100644 --- a/src/Views/Profile.vala +++ b/src/Views/Profile.vala @@ -47,9 +47,9 @@ public class Tootle.Views.Profile : Views.Timeline { profile.bind_property ("display-name", handle, "text", BindingFlags.SYNC_CREATE); note_row = builder.get_object ("note_row") as ListBoxRow; - var note = builder.get_object ("note") as Widgets.RichLabel; - profile.bind_property ("note", note, "text", BindingFlags.SYNC_CREATE, (b, src, ref target) => { - var text = Html.simplify ((string) src); + var note = builder.get_object ("note") as Widgets.MarkupView; + profile.bind_property ("note", note, "content", BindingFlags.SYNC_CREATE, (b, src, ref target) => { + var text = (string) src; target.set_string (text); note_row.visible = text != ""; return true; diff --git a/src/Widgets/Conversation.vala b/src/Widgets/Conversation.vala index a8800c3..052dfc8 100644 --- a/src/Widgets/Conversation.vala +++ b/src/Widgets/Conversation.vala @@ -19,7 +19,7 @@ public class Tootle.Widgets.Conversation : Widgets.Status { owned get { var label = ""; foreach (API.Account account in conversation.accounts) { - label += "" + Html.simplify (account.display_name) + ""; + label += account.display_name; if (conversation.accounts.last () != account) label += ", "; } diff --git a/src/Widgets/MarkupPolicy.vala b/src/Widgets/MarkupPolicy.vala new file mode 100644 index 0000000..c01321d --- /dev/null +++ b/src/Widgets/MarkupPolicy.vala @@ -0,0 +1,27 @@ +public enum Tootle.MarkupPolicy { + + // Remove all tags from the string + DISALLOW, + + // Allow markup, remove unsupported tags from the input string + ALLOW, + + // Allow markup, do nothing with the input string + TRUST; + + public string process (string input) { + switch (this) { + case DISALLOW: + return HtmlUtils.remove_tags (input); + case ALLOW: + return HtmlUtils.simplify (input); + default: + return input; + } + } + + public void apply (Widgets.RichLabel w) { + w.use_markup = this != DISALLOW; + } + +} diff --git a/src/Widgets/MarkupView.vala b/src/Widgets/MarkupView.vala new file mode 100644 index 0000000..12a48b0 --- /dev/null +++ b/src/Widgets/MarkupView.vala @@ -0,0 +1,174 @@ +using Gtk; + +public class Tootle.Widgets.MarkupView : Box { + + public delegate void NodeFn (Xml.Node* node); + public delegate void NodeHandlerFn (MarkupView view, Xml.Node* node); + + string? current_chunk = null; + + string _content = ""; + public string content { + get { + return _content; + } + set { + _content = value; + update_content (_content); + } + } + + private bool _selectable = false; + public bool selectable { + get { return _selectable; } + set { + _selectable = value; + get_children ().foreach (w => { + var label = w as Label; + if (label != null) { + label.selectable = _selectable; + } + }); + } + } + + construct { + orientation = Orientation.VERTICAL; + spacing = 12; + } + + void update_content (string content) { + current_chunk = null; + + get_children ().foreach (w => { + w.destroy (); + }); + + var doc = Html.Doc.read_doc (content, "", "utf8"); + if (doc != null) { + var root = doc->get_root_element (); + if (root != null) { + default_handler (this, root); + } + } + + delete doc; + + visible = get_children ().length () > 0; + } + + static void traverse (Xml.Node* root, owned NodeFn cb) { + for (var iter = root->children; iter != null; iter = iter->next) { + cb (iter); + } + } + + static void traverse_and_handle (MarkupView v, Xml.Node* root, owned NodeHandlerFn handler) { + traverse (root, node => { + handler (v, node); + }); + } + + void commit_chunk () { + if (current_chunk != null && current_chunk != "") { + var label = new RichLabel (current_chunk) { + visible = true, + markup = MarkupPolicy.TRUST, + selectable = _selectable + }; + pack_start (label); + } + current_chunk = null; + } + + void write_chunk (string? chunk) { + if (chunk == null) return; + + if (current_chunk == null) + current_chunk = chunk; + else + current_chunk += chunk; + } + + public static void default_handler (MarkupView v, Xml.Node* root) { + switch (root->name) { + case "html": + case "span": + case "markup": + case "pre": + case "ul": + case "ol": + traverse_and_handle (v, root, default_handler); + break; + case "body": + traverse_and_handle (v, root, default_handler); + v.commit_chunk (); + break; + case "p": + // Don't add spacing if this is the first paragraph + if (v.current_chunk != "" && v.current_chunk != null) + v.write_chunk ("\n\n"); + + traverse_and_handle (v, root, default_handler); + break; + case "code": + case "blockquote": + v.commit_chunk (); + + var text = ""; + traverse (root, (node) => { + switch (node->name) { + case "text": + text += node->content; + break; + default: + break; + } + }); + + var label = new RichLabel (text) { + visible = true, + markup = MarkupPolicy.DISALLOW + }; + label.get_style_context ().add_class ("ttl-code"); + v.pack_start (label); + break; + case "a": + var href = root->get_prop ("href"); + if (href != null) { + v.write_chunk (""); + traverse_and_handle (v, root, default_handler); + v.write_chunk (""); + } + break; + + case "b": + case "i": + case "u": + case "s": + case "sup": + case "sub": + v.write_chunk (@"<$(root->name)>"); + traverse_and_handle (v, root, default_handler); + v.write_chunk (@"name)>"); + break; + + case "li": + v.write_chunk ("\n• "); + traverse_and_handle (v, root, default_handler); + break; + case "br": + v.write_chunk ("\n"); + break; + case "text": + if (root->content != null) + v.write_chunk (GLib.Markup.escape_text (root->content)); + break; + default: + warning (@"Unknown HTML tag: \"$(root->name)\""); + traverse_and_handle (v, root, default_handler); + break; + } + } + +} diff --git a/src/Widgets/RichLabel.vala b/src/Widgets/RichLabel.vala index af37a73..64a27e9 100644 --- a/src/Widgets/RichLabel.vala +++ b/src/Widgets/RichLabel.vala @@ -3,19 +3,30 @@ using Gee; public class Tootle.Widgets.RichLabel : Label { + // TODO: We can parse tags and extract resolvable URIs now public weak ArrayList? mentions; + MarkupPolicy _markup = DISALLOW; + public MarkupPolicy markup { + get { + return _markup; + } + set { + _markup = value; + _markup.apply (this); + } + } + public string text { get { return this.label; } set { - this.label = escape_entities (Html.simplify (value)); + this.label = markup.process (value); } } construct { - use_markup = true; xalign = 0; wrap_mode = Pango.WrapMode.WORD_CHAR; justify = Justification.LEFT; diff --git a/src/Widgets/Status.vala b/src/Widgets/Status.vala index ae90cc1..81b21ae 100644 --- a/src/Widgets/Status.vala +++ b/src/Widgets/Status.vala @@ -42,16 +42,16 @@ public class Tootle.Widgets.Status : ListBoxRow { [GtkChild] public Widgets.Avatar avatar; [GtkChild] protected Widgets.RichLabel name_label; - [GtkChild] protected Widgets.RichLabel handle_label; + [GtkChild] protected Label handle_label; [GtkChild] protected Box indicators; - [GtkChild] protected Widgets.RichLabel date_label; + [GtkChild] protected Label date_label; [GtkChild] protected Image pin_indicator; [GtkChild] protected Image indicator; [GtkChild] protected Box content_column; [GtkChild] protected Stack spoiler_stack; [GtkChild] protected Box content_box; - [GtkChild] protected Widgets.RichLabel content; + [GtkChild] protected Widgets.MarkupView content; [GtkChild] protected Widgets.Attachment.Box attachments; [GtkChild] protected Button spoiler_button; [GtkChild] protected Widgets.RichLabel spoiler_label; @@ -84,7 +84,7 @@ public class Tootle.Widgets.Status : ListBoxRow { public string title_text { owned get { - return Html.simplify (status.formal.account.display_name); + return status.formal.account.display_name; } } @@ -140,9 +140,9 @@ public class Tootle.Widgets.Status : ListBoxRow { reply_button_icon.icon_name = "mail-reply-sender-symbolic"; bind_property ("spoiler-text", spoiler_label, "text", BindingFlags.SYNC_CREATE); - status.formal.bind_property ("content", content, "text", BindingFlags.SYNC_CREATE); + status.formal.bind_property ("content", content, "content", BindingFlags.SYNC_CREATE); bind_property ("title_text", name_label, "text", BindingFlags.SYNC_CREATE); - bind_property ("subtitle_text", handle_label, "text", BindingFlags.SYNC_CREATE); + 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); status.formal.bind_property ("account", avatar, "account", BindingFlags.SYNC_CREATE); @@ -166,9 +166,11 @@ public class Tootle.Widgets.Status : ListBoxRow { if (status.id == "") { actions.destroy (); date_label.destroy (); - content.single_line_mode = true; - content.lines = 2; - content.ellipsize = Pango.EllipsizeMode.END; + + //TODO: this + // content.single_line_mode = true; + // content.lines = 2; + // content.ellipsize = Pango.EllipsizeMode.END; } if (!attachments.populate (status.formal.media_attachments) || status.id == "") { @@ -178,7 +180,7 @@ public class Tootle.Widgets.Status : ListBoxRow { menu_button.clicked.connect (open_menu); } - public Status (API.Status status, API.NotificationType? kind = null) { + public Status (owned API.Status status, API.NotificationType? kind = null) { Object ( status: status, kind: kind @@ -216,7 +218,7 @@ public class Tootle.Widgets.Status : ListBoxRow { item_copy_link.activate.connect (() => Desktop.copy (status.formal.url)); var item_copy = new Gtk.MenuItem.with_label (_("Copy Text")); item_copy.activate.connect (() => { - var sanitized = Html.remove_tags (status.formal.content); + var sanitized = HtmlUtils.remove_tags (status.formal.content); Desktop.copy (sanitized); }); @@ -261,14 +263,14 @@ public class Tootle.Widgets.Status : ListBoxRow { public void expand_root () { activatable = false; - content.selectable = true; - content.get_style_context ().add_class ("ttl-large-body"); + content.selectable = true; + content.get_style_context ().add_class ("ttl-large-body"); - var parent = content_column.get_parent () as Container; - var left_attach = parent.find_child_property ("left-attach"); - var width = parent.find_child_property ("width"); - parent.set_child_property (content_column, 1, 0, left_attach); - parent.set_child_property (content_column, 3, 2, width); + var parent = content_column.get_parent () as Container; + var left_attach = parent.find_child_property ("left-attach"); + var width = parent.find_child_property ("width"); + parent.set_child_property (content_column, 1, 0, left_attach); + parent.set_child_property (content_column, 3, 2, width); } public void install_thread_line () {