mirror of
https://github.com/TakeV-Lambda/Tooth.git
synced 2024-10-31 21:20:24 +00:00
feat: polls (#25)
* poll support * feat: let spoiler button handle poll's spoiler status * fix: change the design to match both mastodon and hig * feat: voted indicator * feat: treat voted as expired Co-authored-by: Evangelos Paterakis <evan@geopjr.dev>
This commit is contained in:
parent
f187f49e60
commit
8f30b48b88
14 changed files with 351 additions and 4 deletions
|
@ -61,6 +61,7 @@
|
||||||
<file>ui/widgets/list_item.ui</file>
|
<file>ui/widgets/list_item.ui</file>
|
||||||
<file>ui/widgets/list_editor_item.ui</file>
|
<file>ui/widgets/list_editor_item.ui</file>
|
||||||
<file>ui/widgets/compose_attachment.ui</file>
|
<file>ui/widgets/compose_attachment.ui</file>
|
||||||
|
<file>ui/widgets/votebox.ui</file>
|
||||||
<file>ui/dialogs/new_account.ui</file>
|
<file>ui/dialogs/new_account.ui</file>
|
||||||
<file>ui/dialogs/compose.ui</file>
|
<file>ui/dialogs/compose.ui</file>
|
||||||
<file>ui/dialogs/main.ui</file>
|
<file>ui/dialogs/main.ui</file>
|
||||||
|
|
|
@ -218,6 +218,10 @@
|
||||||
<property name="hexpand">True</property>
|
<property name="hexpand">True</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="ToothWidgetsVoteBox" id="poll">
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="ToothWidgetsAttachmentBox" id="attachments"/>
|
<object class="ToothWidgetsAttachmentBox" id="attachments"/>
|
||||||
</child>
|
</child>
|
||||||
|
|
37
data/ui/widgets/votebox.ui
Normal file
37
data/ui/widgets/votebox.ui
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<interface>
|
||||||
|
<requires lib="gtk" version="4.0"/>
|
||||||
|
<template class="ToothWidgetsVoteBox" parent="GtkBox">
|
||||||
|
<property name="margin_top">12</property>
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkListBox" id="pollBox">
|
||||||
|
<child>
|
||||||
|
<placeholder/>
|
||||||
|
</child>
|
||||||
|
<style>
|
||||||
|
<class name="boxed-list"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="pollActionBox">
|
||||||
|
<property name="margin_top">10</property>
|
||||||
|
<property name="spacing">12</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="button_vote">
|
||||||
|
<property name="visible">0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="people_label">
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="expires_label">
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</template>
|
||||||
|
</interface>
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
gi{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
|
|
|
@ -57,6 +57,8 @@ sources = files(
|
||||||
'src/API/SearchResults.vala',
|
'src/API/SearchResults.vala',
|
||||||
'src/API/Status.vala',
|
'src/API/Status.vala',
|
||||||
'src/API/Tag.vala',
|
'src/API/Tag.vala',
|
||||||
|
'src/API/Poll.vala',
|
||||||
|
'src/API/PollOption.vala',
|
||||||
'src/Application.vala',
|
'src/Application.vala',
|
||||||
'src/Dialogs/Composer/AttachmentsPage.vala',
|
'src/Dialogs/Composer/AttachmentsPage.vala',
|
||||||
'src/Dialogs/Composer/Dialog.vala',
|
'src/Dialogs/Composer/Dialog.vala',
|
||||||
|
@ -120,6 +122,8 @@ sources = files(
|
||||||
'src/Widgets/Status.vala',
|
'src/Widgets/Status.vala',
|
||||||
'src/Widgets/StatusActionButton.vala',
|
'src/Widgets/StatusActionButton.vala',
|
||||||
'src/Widgets/Widgetizable.vala',
|
'src/Widgets/Widgetizable.vala',
|
||||||
|
'src/Widgets/VoteBox.vala',
|
||||||
|
'src/Widgets/VoteCheckButton.vala',
|
||||||
)
|
)
|
||||||
|
|
||||||
build_file = configure_file(
|
build_file = configure_file(
|
||||||
|
|
|
@ -295,6 +295,14 @@ msgstr ""
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/Widgets/VoteBox.vala:17
|
||||||
|
msgid "Vote"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/Widgets/VoteBox.vala:87
|
||||||
|
msgid "Expires at: %s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: data/ui/widgets/list_item.ui:26 src/Dialogs/ListEditor.vala:87
|
#: data/ui/widgets/list_item.ui:26 src/Dialogs/ListEditor.vala:87
|
||||||
msgid "Untitled"
|
msgid "Untitled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
@ -103,7 +103,7 @@ public class Tooth.Entity : GLib.Object, Widgetizable, Json.Serializable {
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool des_list (out Value val, Json.Node node, Type type) {
|
public static bool des_list (out Value val, Json.Node node, Type type) {
|
||||||
if (!node.is_null ()) {
|
if (!node.is_null ()) {
|
||||||
var arr = new Gee.ArrayList<Entity> ();
|
var arr = new Gee.ArrayList<Entity> ();
|
||||||
node.get_array ().foreach_element ((array, i, elem) => {
|
node.get_array ().foreach_element ((array, i, elem) => {
|
||||||
|
|
78
src/API/Poll.vala
Executable file
78
src/API/Poll.vala
Executable file
|
@ -0,0 +1,78 @@
|
||||||
|
using Gee;
|
||||||
|
using Json;
|
||||||
|
|
||||||
|
public class Tooth.API.Poll : GLib.Object, Json.Serializable{
|
||||||
|
public string id { get; set; }
|
||||||
|
public string expires_at{ get; set; }
|
||||||
|
public bool expired { get; set; }
|
||||||
|
public bool multiple { get; set; }
|
||||||
|
public int64 votes_count { get; set; }
|
||||||
|
public int64 voters_count { get; set; }
|
||||||
|
public bool voted { get; set; default = true;}
|
||||||
|
public ArrayList<int> own_votes { get; set; }
|
||||||
|
public ArrayList<PollOption>? options{ get; set; default = null; }
|
||||||
|
|
||||||
|
public Poll (string _id) {
|
||||||
|
id = _id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool deserialize_property (string prop, out Value val, ParamSpec spec, Json.Node node) {
|
||||||
|
var success = default_deserialize_property (prop, out val, spec, node);
|
||||||
|
|
||||||
|
var type = spec.value_type;
|
||||||
|
if (prop=="options"){
|
||||||
|
return Entity.des_list (out val, node, typeof (API.PollOption));
|
||||||
|
}
|
||||||
|
if (prop=="own-votes"){
|
||||||
|
return Poll.des_list_int (out val, node);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
public static bool des_list_int (out Value val, Json.Node node) {
|
||||||
|
if (!node.is_null ()) {
|
||||||
|
var arr = new Gee.ArrayList<int> ();
|
||||||
|
node.get_array ().foreach_element ((array, i, elem) => {
|
||||||
|
arr.add ((int)elem.get_int());
|
||||||
|
});
|
||||||
|
val = arr;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public static Poll from_json (Type type, Json.Node? node) throws Oopsie {
|
||||||
|
if (node == null)
|
||||||
|
throw new Oopsie.PARSING (@"Received Json.Node for $(type.name ()) is null!");
|
||||||
|
|
||||||
|
var obj = node.get_object ();
|
||||||
|
if (obj == null)
|
||||||
|
throw new Oopsie.PARSING (@"Received Json.Node for $(type.name ()) is not a Json.Object!");
|
||||||
|
|
||||||
|
return Json.gobject_deserialize (type, node) as Poll;
|
||||||
|
}
|
||||||
|
public static Request vote (InstanceAccount acc,ArrayList<PollOption> options,ArrayList<string> selection, string id) {
|
||||||
|
message (@"Voting poll $(id)...");
|
||||||
|
//Creating json to send
|
||||||
|
var builder = new Json.Builder ();
|
||||||
|
builder.begin_object ();
|
||||||
|
builder.set_member_name ("choices");
|
||||||
|
builder.begin_array ();
|
||||||
|
var row_number=0;
|
||||||
|
foreach (API.PollOption p in options){
|
||||||
|
foreach (string select in selection){
|
||||||
|
if (select == p.title){
|
||||||
|
builder.add_string_value (row_number.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row_number++;
|
||||||
|
}
|
||||||
|
builder.end_array ();
|
||||||
|
builder.end_object ();
|
||||||
|
var generator = new Json.Generator ();
|
||||||
|
generator.set_root (builder.get_root ());
|
||||||
|
var json = generator.to_data (null);
|
||||||
|
//Send POST MESSAGE
|
||||||
|
Request voting=new Request.POST (@"/api/v1/polls/$(id)/votes")
|
||||||
|
.with_account (acc);
|
||||||
|
voting.set_request("application/json",Soup.MemoryUse.COPY,json.data);
|
||||||
|
return voting;
|
||||||
|
}
|
||||||
|
}
|
4
src/API/PollOption.vala
Executable file
4
src/API/PollOption.vala
Executable file
|
@ -0,0 +1,4 @@
|
||||||
|
public class Tooth.API.PollOption: Entity {
|
||||||
|
public string? title { get; set; }
|
||||||
|
public int64 votes_count{ get; set; }
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ public class Tooth.API.Status : Entity, Widgetizable {
|
||||||
public API.Status? reblog { get; set; default = null; }
|
public API.Status? reblog { get; set; default = null; }
|
||||||
public ArrayList<API.Mention>? mentions { get; set; default = null; }
|
public ArrayList<API.Mention>? mentions { get; set; default = null; }
|
||||||
public ArrayList<API.Attachment>? media_attachments { get; set; default = null; }
|
public ArrayList<API.Attachment>? media_attachments { get; set; default = null; }
|
||||||
|
public API.Poll? poll { get; set; default = null; }
|
||||||
|
|
||||||
public string? t_url { get; set; }
|
public string? t_url { get; set; }
|
||||||
public string url {
|
public string url {
|
||||||
|
|
|
@ -2,12 +2,62 @@ using GLib;
|
||||||
|
|
||||||
public class Tooth.DateTime {
|
public class Tooth.DateTime {
|
||||||
|
|
||||||
|
public static string humanize_left (string iso8601) {
|
||||||
|
var date = new GLib.DateTime.from_iso8601 (iso8601, null);
|
||||||
|
var now = new GLib.DateTime.now_local ();
|
||||||
|
var delta = date.difference (now);
|
||||||
|
if (delta < 0) {
|
||||||
|
return humanize(iso8601);
|
||||||
|
} else if (delta <= TimeSpan.MINUTE) {
|
||||||
|
return _("expires soon");
|
||||||
|
} else if (delta < TimeSpan.HOUR) {
|
||||||
|
var minutes = delta / TimeSpan.MINUTE;
|
||||||
|
return _(@"$(minutes)m left");
|
||||||
|
} else if (delta <= TimeSpan.DAY) {
|
||||||
|
var hours = delta / TimeSpan.HOUR;
|
||||||
|
return _(@"$(hours)h left");
|
||||||
|
} else if (delta <= (TimeSpan.DAY * 60)) {
|
||||||
|
var days = delta / TimeSpan.DAY;
|
||||||
|
return _(@"$(days)d left");
|
||||||
|
} else {
|
||||||
|
return date.format (_("expires on %b %e, %Y"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string humanize_ago (string iso8601) {
|
||||||
|
var date = new GLib.DateTime.from_iso8601 (iso8601, null);
|
||||||
|
var now = new GLib.DateTime.now_local ();
|
||||||
|
var delta = now.difference (date);
|
||||||
|
if (delta < 0)
|
||||||
|
return date.format (_("expires on %b %e, %Y %H:%m"));
|
||||||
|
else if (delta <= TimeSpan.MINUTE)
|
||||||
|
return _("expired on just now");
|
||||||
|
else if (delta < TimeSpan.HOUR) {
|
||||||
|
var minutes = delta / TimeSpan.MINUTE;
|
||||||
|
return _(@"expired $(minutes)m ago");
|
||||||
|
}
|
||||||
|
else if (delta <= TimeSpan.DAY) {
|
||||||
|
var hours = delta / TimeSpan.HOUR;
|
||||||
|
return _(@"expired $(hours)h ago");
|
||||||
|
}
|
||||||
|
else if (is_same_day (now, date.add_days (1))) {
|
||||||
|
return _("expired yesterday");
|
||||||
|
}
|
||||||
|
else if (date.get_year () == now.get_year ()) {
|
||||||
|
return date.format (_("expired on %b %e"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return date.format (_("expired on %b %e, %Y"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static string humanize (string iso8601) {
|
public static string humanize (string iso8601) {
|
||||||
var date = new GLib.DateTime.from_iso8601 (iso8601, null);
|
var date = new GLib.DateTime.from_iso8601 (iso8601, null);
|
||||||
var now = new GLib.DateTime.now_local ();
|
var now = new GLib.DateTime.now_local ();
|
||||||
var delta = now.difference (date);
|
var delta = now.difference (date);
|
||||||
|
if (delta < 0)
|
||||||
if (delta <= TimeSpan.MINUTE)
|
return date.format (_("%b %e, %Y %H:%m"));
|
||||||
|
else if (delta <= TimeSpan.MINUTE)
|
||||||
return _("Just now");
|
return _("Just now");
|
||||||
else if (delta < TimeSpan.HOUR) {
|
else if (delta < TimeSpan.HOUR) {
|
||||||
var minutes = delta / TimeSpan.MINUTE;
|
var minutes = delta / TimeSpan.MINUTE;
|
||||||
|
|
|
@ -58,6 +58,8 @@ public class Tooth.Widgets.Status : ListBoxRow {
|
||||||
|
|
||||||
[GtkChild] public unowned Box actions;
|
[GtkChild] public unowned Box actions;
|
||||||
|
|
||||||
|
[GtkChild] public unowned Widgets.VoteBox poll;
|
||||||
|
|
||||||
protected Button reply_button;
|
protected Button reply_button;
|
||||||
protected Adw.ButtonContent reply_button_content;
|
protected Adw.ButtonContent reply_button_content;
|
||||||
protected StatusActionButton reblog_button;
|
protected StatusActionButton reblog_button;
|
||||||
|
@ -247,6 +249,14 @@ public class Tooth.Widgets.Status : ListBoxRow {
|
||||||
date_label.destroy ();
|
date_label.destroy ();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.poll==null){
|
||||||
|
poll.hide();
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
poll.status_parent=status;
|
||||||
|
status.bind_property ("poll", poll, "poll", BindingFlags.SYNC_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
attachments.list = status.formal.media_attachments;
|
attachments.list = status.formal.media_attachments;
|
||||||
}
|
}
|
||||||
|
|
139
src/Widgets/VoteBox.vala
Normal file
139
src/Widgets/VoteBox.vala
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
using Gtk;
|
||||||
|
using Gdk;
|
||||||
|
using Gee;
|
||||||
|
|
||||||
|
[GtkTemplate (ui = "/dev/geopjr/tooth/ui/widgets/votebox.ui")]
|
||||||
|
public class Tooth.Widgets.VoteBox: Box {
|
||||||
|
[GtkChild] protected ListBox pollBox;
|
||||||
|
[GtkChild] protected Button button_vote;
|
||||||
|
[GtkChild] protected Box pollActionBox;
|
||||||
|
[GtkChild] protected Label people_label;
|
||||||
|
[GtkChild] protected Label expires_label;
|
||||||
|
|
||||||
|
public API.Poll? poll { get; set;}
|
||||||
|
public API.Status? status_parent{ get; set;}
|
||||||
|
|
||||||
|
|
||||||
|
protected ArrayList<string> selectedIndex=new ArrayList<string>();
|
||||||
|
|
||||||
|
construct{
|
||||||
|
button_vote.set_label (_("Vote"));
|
||||||
|
button_vote.clicked.connect ((button) =>{
|
||||||
|
Request voting=API.Poll.vote(accounts.active,poll.options,selectedIndex,poll.id);
|
||||||
|
voting.then ((sess, mess) => {
|
||||||
|
status_parent.poll=API.Poll.from_json(typeof(API.Poll),network.parse_node (mess));
|
||||||
|
})
|
||||||
|
.on_error ((code, reason) => {}).exec ();
|
||||||
|
});
|
||||||
|
notify["poll"].connect (update);
|
||||||
|
button_vote.sensitive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string generate_css_style(int percentage) {
|
||||||
|
return @".ttl-poll-$(percentage).ttl-poll-winner { background: linear-gradient(to right, alpha(@accent_bg_color, .5) $(percentage)%, transparent 0%); } .ttl-poll-$(percentage) { background: linear-gradient(to right, alpha(@view_fg_color, .1) $(percentage)%, transparent 0%); }";
|
||||||
|
}
|
||||||
|
|
||||||
|
void update(){
|
||||||
|
var row_number=0;
|
||||||
|
var winner_p = 0.0;
|
||||||
|
|
||||||
|
Adw.ActionRow last_winner = null;
|
||||||
|
Widgets.VoteCheckButton group_radio_option = null;
|
||||||
|
|
||||||
|
//clear all existing entries
|
||||||
|
Widget entry=pollBox.get_first_child();
|
||||||
|
while(entry!=null){
|
||||||
|
pollBox.remove(entry);
|
||||||
|
entry=pollBox.get_first_child();
|
||||||
|
}
|
||||||
|
//Reset button visibility
|
||||||
|
button_vote.set_visible(false);
|
||||||
|
if(!poll.expired && !poll.voted){
|
||||||
|
button_vote.set_visible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (poll.expired) {
|
||||||
|
// pollBox.sensitive = false;
|
||||||
|
// }
|
||||||
|
//creates the entries of poll
|
||||||
|
foreach (API.PollOption p in poll.options){
|
||||||
|
var row = new Adw.ActionRow ();
|
||||||
|
//if it is own poll
|
||||||
|
if(poll.expired || poll.voted){
|
||||||
|
// If multiple, Checkbox else radioButton
|
||||||
|
var percentage = ((double)p.votes_count/poll.votes_count)*100;
|
||||||
|
|
||||||
|
var provider = new Gtk.CssProvider ();
|
||||||
|
provider.load_from_data(generate_css_style((int) percentage).data);
|
||||||
|
row.get_style_context ().add_provider (provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||||
|
row.add_css_class(@"ttl-poll-$((int) percentage)");
|
||||||
|
|
||||||
|
if (percentage > winner_p) {
|
||||||
|
winner_p = percentage;
|
||||||
|
if (last_winner != null)
|
||||||
|
last_winner.remove_css_class("ttl-poll-winner");
|
||||||
|
row.add_css_class("ttl-poll-winner");
|
||||||
|
last_winner = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (int own_vote in poll.own_votes){
|
||||||
|
if (own_vote==row_number){
|
||||||
|
row.add_suffix(new Image.from_icon_name("tooth-check-round-outline-symbolic"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.title = "%.1f%%".printf(percentage);
|
||||||
|
row.subtitle = p.title;
|
||||||
|
pollBox.append(row);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
row.title = p.title;
|
||||||
|
var check_option = new Widgets.VoteCheckButton ();
|
||||||
|
|
||||||
|
if (!poll.multiple){
|
||||||
|
if (row_number==0){
|
||||||
|
group_radio_option=check_option;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
check_option.set_group(group_radio_option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check_option.poll_title = p.title;
|
||||||
|
check_option.toggled.connect((radio)=>{
|
||||||
|
var radio_votebutton = radio as Widgets.VoteCheckButton;
|
||||||
|
if (selectedIndex.contains(radio_votebutton.poll_title)){
|
||||||
|
selectedIndex.remove(radio_votebutton.poll_title);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
selectedIndex.add(radio_votebutton.poll_title);
|
||||||
|
}
|
||||||
|
button_vote.sensitive = selectedIndex.size > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (int own_vote in poll.own_votes){
|
||||||
|
if (own_vote==row_number){
|
||||||
|
check_option.set_active(true);
|
||||||
|
row.add_suffix(new Image.from_icon_name("tooth-check-round-outline-symbolic"));
|
||||||
|
if (!selectedIndex.contains(p.title)){
|
||||||
|
selectedIndex.add(p.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(poll.expired || poll.voted){
|
||||||
|
check_option.set_sensitive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.add_prefix(check_option);
|
||||||
|
row.activatable_widget = check_option;
|
||||||
|
|
||||||
|
pollBox.append(row);
|
||||||
|
}
|
||||||
|
row_number++;
|
||||||
|
}
|
||||||
|
|
||||||
|
people_label.label = _("%lld voted").printf(poll.votes_count);
|
||||||
|
expires_label.label = poll.expired ? DateTime.humanize_ago(poll.expires_at) : DateTime.humanize_left(poll.expires_at);
|
||||||
|
}
|
||||||
|
}
|
11
src/Widgets/VoteCheckButton.vala
Normal file
11
src/Widgets/VoteCheckButton.vala
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using Gtk;
|
||||||
|
using Gdk;
|
||||||
|
|
||||||
|
public class Tooth.Widgets.VoteCheckButton : CheckButton {
|
||||||
|
public string poll_title { get; set;}
|
||||||
|
|
||||||
|
public VoteCheckButton () {
|
||||||
|
Object ();
|
||||||
|
this.add_css_class("selection-mode");
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue