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