Introduce MarkupView widget (#264)
Co-authored-by: Bleak Grey <bleakgrey@gmail.com>
This commit is contained in:
parent
3cf8d9ddd6
commit
02f168e19d
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
<property name="can_focus">False</property>
|
||||
<property name="vhomogeneous">False</property>
|
||||
<property name="transition_type">crossfade</property>
|
||||
<property name="interpolate_size">True</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="status">
|
||||
<property name="visible">True</property>
|
||||
|
|
|
@ -100,16 +100,14 @@
|
|||
<property name="activatable">False</property>
|
||||
<property name="selectable">False</property>
|
||||
<child>
|
||||
<object class="TootleWidgetsRichLabel" id="note">
|
||||
<object class="TootleWidgetsMarkupView" id="note">
|
||||
<property name="visible">True</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">8</property>
|
||||
<property name="margin_right">8</property>
|
||||
<property name="margin_top">8</property>
|
||||
<property name="margin_bottom">8</property>
|
||||
<property name="selectable">True</property>
|
||||
<property name="width_chars">25</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
|
@ -48,6 +48,7 @@
|
|||
<property name="wrap">True</property>
|
||||
<property name="wrap_mode">word-char</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="markup">allow</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="left_attach">2</property>
|
||||
|
|
|
@ -57,6 +57,7 @@
|
|||
<property name="xalign">0</property>
|
||||
<property name="margin-bottom">8</property>
|
||||
<property name="track_visited_links">false</property>
|
||||
<property name="markup">trust</property>
|
||||
<style>
|
||||
<class name="title-4"/>
|
||||
</style>
|
||||
|
@ -91,13 +92,14 @@
|
|||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="TootleWidgetsRichLabel" id="handle_label">
|
||||
<object class="GtkLabel" id="handle_label">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="no">Handle</property>
|
||||
<property name="opacity">0.5</property>
|
||||
<property name="ellipsize">end</property>
|
||||
<property name="single_line_mode">True</property>
|
||||
<property name="xalign">0</property>
|
||||
<style>
|
||||
<class name="body"/>
|
||||
</style>
|
||||
|
@ -143,11 +145,12 @@
|
|||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="TootleWidgetsRichLabel" id="date_label">
|
||||
<object class="GtkLabel" id="date_label">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="no">Yesterday</property>
|
||||
<property name="opacity">0.5</property>
|
||||
<property name="xalign">0</property>
|
||||
<style>
|
||||
<class name="body"/>
|
||||
</style>
|
||||
|
@ -241,15 +244,10 @@
|
|||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">8</property>
|
||||
<child>
|
||||
<object class="TootleWidgetsRichLabel" id="content">
|
||||
<object class="TootleWidgetsMarkupView" id="content">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="label">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.</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="wrap_mode">word-char</property>
|
||||
<property name="width_chars">15</property>
|
||||
<property name="xalign">0</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -19,7 +19,7 @@ public class Tootle.Widgets.Conversation : Widgets.Status {
|
|||
owned get {
|
||||
var label = "";
|
||||
foreach (API.Account account in conversation.accounts) {
|
||||
label += "<b>" + Html.simplify (account.display_name) + "</b>";
|
||||
label += account.display_name;
|
||||
if (conversation.accounts.last () != account)
|
||||
label += ", ";
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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 ("<a href='" + GLib.Markup.escape_text (href) + "'>");
|
||||
traverse_and_handle (v, root, default_handler);
|
||||
v.write_chunk ("</a>");
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -3,19 +3,30 @@ using Gee;
|
|||
|
||||
public class Tootle.Widgets.RichLabel : Label {
|
||||
|
||||
// TODO: We can parse <a> tags and extract resolvable URIs now
|
||||
public weak ArrayList<API.Mention>? 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;
|
||||
|
|
|
@ -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 () {
|
||||
|
|
Loading…
Reference in New Issue