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:
Juan Campos 2022-12-06 03:54:18 +01:00 committed by GitHub
parent f187f49e60
commit 8f30b48b88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 351 additions and 4 deletions

View File

@ -61,6 +61,7 @@
<file>ui/widgets/list_item.ui</file>
<file>ui/widgets/list_editor_item.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/compose.ui</file>
<file>ui/dialogs/main.ui</file>

View File

@ -218,6 +218,10 @@
<property name="hexpand">True</property>
</object>
</child>
<child>
<object class="ToothWidgetsVoteBox" id="poll">
</object>
</child>
<child>
<object class="ToothWidgetsAttachmentBox" id="attachments"/>
</child>

View 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>

View File

@ -1,4 +1,4 @@
{
gi{
"nodes": {
"nixpkgs": {
"locked": {

View File

@ -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(

View File

@ -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 ""

View File

@ -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<Entity> ();
node.get_array ().foreach_element ((array, i, elem) => {

78
src/API/Poll.vala Executable file
View 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
View File

@ -0,0 +1,4 @@
public class Tooth.API.PollOption: Entity {
public string? title { get; set; }
public int64 votes_count{ get; set; }
}

View File

@ -27,6 +27,7 @@ public class Tooth.API.Status : Entity, Widgetizable {
public API.Status? reblog { get; set; default = null; }
public ArrayList<API.Mention>? mentions { 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 url {

View File

@ -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;

View File

@ -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;
}

139
src/Widgets/VoteBox.vala Normal file
View 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);
}
}

View 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");
}
}