diff --git a/data/gresource.xml b/data/gresource.xml index cf71d3b..650e28c 100644 --- a/data/gresource.xml +++ b/data/gresource.xml @@ -61,6 +61,7 @@ ui/widgets/list_item.ui ui/widgets/list_editor_item.ui ui/widgets/compose_attachment.ui + ui/widgets/votebox.ui ui/dialogs/new_account.ui ui/dialogs/compose.ui ui/dialogs/main.ui diff --git a/data/ui/widgets/status.ui b/data/ui/widgets/status.ui index 2e75a5b..bb3281f 100644 --- a/data/ui/widgets/status.ui +++ b/data/ui/widgets/status.ui @@ -218,6 +218,10 @@ True + + + + diff --git a/data/ui/widgets/votebox.ui b/data/ui/widgets/votebox.ui new file mode 100644 index 0000000..8f08797 --- /dev/null +++ b/data/ui/widgets/votebox.ui @@ -0,0 +1,37 @@ + + + + + diff --git a/flake.lock b/flake.lock index 8856779..f47db58 100644 --- a/flake.lock +++ b/flake.lock @@ -1,4 +1,4 @@ -{ +gi{ "nodes": { "nixpkgs": { "locked": { diff --git a/meson.build b/meson.build index 2f5b461..585678d 100644 --- a/meson.build +++ b/meson.build @@ -57,6 +57,8 @@ sources = files( 'src/API/SearchResults.vala', 'src/API/Status.vala', 'src/API/Tag.vala', + 'src/API/Poll.vala', + 'src/API/PollOption.vala', 'src/Application.vala', 'src/Dialogs/Composer/AttachmentsPage.vala', 'src/Dialogs/Composer/Dialog.vala', @@ -120,6 +122,8 @@ sources = files( 'src/Widgets/Status.vala', 'src/Widgets/StatusActionButton.vala', 'src/Widgets/Widgetizable.vala', + 'src/Widgets/VoteBox.vala', + 'src/Widgets/VoteCheckButton.vala', ) build_file = configure_file( diff --git a/po/dev.geopjr.tooth.pot b/po/dev.geopjr.tooth.pot index f9ba621..b7d3f3e 100644 --- a/po/dev.geopjr.tooth.pot +++ b/po/dev.geopjr.tooth.pot @@ -295,6 +295,14 @@ msgstr "" msgid "Delete" 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 msgid "Untitled" msgstr "" diff --git a/src/API/Entity.vala b/src/API/Entity.vala index 9f2808b..cb6c638 100644 --- a/src/API/Entity.vala +++ b/src/API/Entity.vala @@ -103,7 +103,7 @@ public class Tooth.Entity : GLib.Object, Widgetizable, Json.Serializable { 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 ()) { var arr = new Gee.ArrayList (); node.get_array ().foreach_element ((array, i, elem) => { diff --git a/src/API/Poll.vala b/src/API/Poll.vala new file mode 100755 index 0000000..97347bf --- /dev/null +++ b/src/API/Poll.vala @@ -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 own_votes { get; set; } + public ArrayList? 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 (); + 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 options,ArrayList 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; + } +} diff --git a/src/API/PollOption.vala b/src/API/PollOption.vala new file mode 100755 index 0000000..59799fe --- /dev/null +++ b/src/API/PollOption.vala @@ -0,0 +1,4 @@ +public class Tooth.API.PollOption: Entity { + public string? title { get; set; } + public int64 votes_count{ get; set; } +} diff --git a/src/API/Status.vala b/src/API/Status.vala index 897735e..fbc109d 100644 --- a/src/API/Status.vala +++ b/src/API/Status.vala @@ -27,6 +27,7 @@ public class Tooth.API.Status : Entity, Widgetizable { public API.Status? reblog { get; set; default = null; } public ArrayList? mentions { get; set; default = null; } public ArrayList? media_attachments { get; set; default = null; } + public API.Poll? poll { get; set; default = null; } public string? t_url { get; set; } public string url { diff --git a/src/Utils/DateTime.vala b/src/Utils/DateTime.vala index 547cf04..e14cd87 100644 --- a/src/Utils/DateTime.vala +++ b/src/Utils/DateTime.vala @@ -2,12 +2,62 @@ using GLib; 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) { var date = new GLib.DateTime.from_iso8601 (iso8601, null); var now = new GLib.DateTime.now_local (); var delta = now.difference (date); - - if (delta <= TimeSpan.MINUTE) + if (delta < 0) + return date.format (_("%b %e, %Y %H:%m")); + else if (delta <= TimeSpan.MINUTE) return _("Just now"); else if (delta < TimeSpan.HOUR) { var minutes = delta / TimeSpan.MINUTE; diff --git a/src/Widgets/Status.vala b/src/Widgets/Status.vala index d9d4195..283d981 100644 --- a/src/Widgets/Status.vala +++ b/src/Widgets/Status.vala @@ -58,6 +58,8 @@ public class Tooth.Widgets.Status : ListBoxRow { [GtkChild] public unowned Box actions; + [GtkChild] public unowned Widgets.VoteBox poll; + protected Button reply_button; protected Adw.ButtonContent reply_button_content; protected StatusActionButton reblog_button; @@ -247,6 +249,14 @@ public class Tooth.Widgets.Status : ListBoxRow { 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.list = status.formal.media_attachments; } diff --git a/src/Widgets/VoteBox.vala b/src/Widgets/VoteBox.vala new file mode 100644 index 0000000..c9f4236 --- /dev/null +++ b/src/Widgets/VoteBox.vala @@ -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 selectedIndex=new ArrayList(); + + 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); + } +} diff --git a/src/Widgets/VoteCheckButton.vala b/src/Widgets/VoteCheckButton.vala new file mode 100644 index 0000000..7548211 --- /dev/null +++ b/src/Widgets/VoteCheckButton.vala @@ -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"); + } +}