Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Polls support #60

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion design/notifications.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
{{{...note.content}}}
</div>
{{/isEq}}
{{#isEq notification.type "Vote"}}
<div class="notification">🗳 <a href="/private/profile/{{getUsername ../actor.id}}">{{../actor.name}}</a> voted on <a href="{{../note.id}}">your poll</a> {{timesince ../time}}</div>
{{/isEq}}
{{#isEq notification.type "Follow"}}
<div class="notification">🤷🏽‍♂️ <a href="/private/profile/{{getUsername ../actor.id}}">{{../actor.name}}</a> followed you {{timesince ../time}}</div>
{{> byline actor=../actor}}
Expand All @@ -42,4 +45,4 @@
{{/if}}{{/each}}

app.pollForPosts();
</script>
</script>
153 changes: 142 additions & 11 deletions design/partials/composer.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,154 @@
{{else}}
<Header>Compose</Header>
{{/if}}
<form onsubmit="return app.post()" id="composer">
<textarea id="post" required placeholder="The coolest thing I can think of right now..." onfocus="this.value = this.value;">{{#if actor}}@{{getUsername actor.id}} {{/if}}</textarea>
<input type="text" id="cw" placeholder="content warning" />
<form onsubmit="return validatePoll() && app.post()" id="composer">
{{#if names}} <!-- When voting, show the user's choice before posting -->
Vote for:
<ul>
{{#each names}}
<li>{{this}}</li>
{{/each}}
</ul>
<textarea id="post" hidden></textarea> <!-- no post content on poll vote -->
<input type="text" id="cw" hidden /> <!-- no cw on poll vote -->
{{else}}
<textarea id="post" required placeholder="The coolest thing I can think of right now..." onfocus="this.value = this.value;">{{#if actor}}@{{getUsername actor.id}} {{/if}}</textarea>
<input type="text" id="cw" placeholder="content warning" />
<div id="polldesigner" style="display:none"></div> <!-- The interactive poll designer -->
<input id="polldata" type="text" hidden> <!-- JSON data for the designed poll -->
<button id="togglepoll" type="button">📊</button>
{{/if}}
<input id="inReplyTo" placeholder="in reply to" value="{{inReplyTo}}" hidden />
<input id="to" placeholder="to" value="{{to}}" hidden />
{{#each names}} <!-- replying to a post containing a poll, embed user's choice in field so it can be posted -->
<input class="pollchoice" value="{{{this}}}" hidden />
{{/each}}
<button id="submit" type="submit">Post</button>
</form>
</div>
<script>
let polldata;

// scroll to bottom
var div = document.getElementById("main");
div.scrollTop = div.scrollHeight;
let times = {
'5 minutes': 5 * 60,
'30 minutes': 30 * 60,
'1 hour': 60 * 60,
'6 hours': 6 * 60 * 60,
'1 day': 24 * 60 * 60,
'3 days': 3 * 24 * 60 * 60,
'7 days': 7 * 24 * 60 * 60
};

// focus the text input
const input = document.getElementById('post');
input.focus();
input.selectionStart = input.selectionEnd = input.value.length;
function pollReset() {
polldata = {
type: 'oneOf',
choices: [null, null],
time: 86400,
};
}

</script>
function escapeHTML(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

function addChoice() {
polldata.choices.push(null);
updatePollHTML(false);
}

function deleteChoice(n) {
polldata.choices.splice(n, 1);
updatePollHTML(false);
}

function changeChoiceText(n, value) {
polldata.choices[n] = value;
updatePollHTML(true);
}

function togglePollType() {
if (polldata.type === 'oneOf') {
polldata.type = 'anyOf';
} else {
polldata.type = 'oneOf';
}
updatePollHTML(false);
return false;
}

function updateTime(value) {
polldata.time = parseInt(value);
updatePollHTML(true);
}

function validatePoll() {
if (document.getElementById('polldesigner')) {
if (document.getElementById('polldesigner').style.display === 'none') {
return true;
}
return !polldata.choices.includes(null); // undefined elements in poll
} else {
return true;
}
}

function updatePollHTML(dataOnly) {
let div = document.getElementById('polldesigner');
if (!dataOnly) {
let html = '';
html += '<form>';
for (let i=0;i<polldata.choices.length;i++) {
let radioOrCheckbox = '<a href="#" onclick="togglePollType()">' + (polldata.type == 'oneOf' ? '🔘' : '🔲') + '</a>';
let nameBox = '<input type="text" value="' + escapeHTML(polldata.choices[i] ? polldata.choices[i] : '') + '" size="20" onChange="changeChoiceText(' + i + ', this.value)" placeholder="Choice ' + (i+1) + '">';
let deleteButton = '<a href="#" onclick="deleteChoice(' + i + ')">' + (i > 1 ? '❎' : '') + '</a>';
html += radioOrCheckbox + nameBox + deleteButton + '<br>';
}

html += '<input type="button" value="➕ Add a choice" onclick="addChoice()">';
html += '<select id="time" name="time" onChange="updateTime(this.value)">';
Object.keys(times).forEach((k) => {
html += ' <option value="' + times[k] + '" ' + (polldata.time == times[k] ? 'selected' : '') + '>' + k + '</option>';
});
html += '</select>';
html += '</form>';
// update display
div.innerHTML = html;
}
// update hidden field with data so that it gets posted
if (validatePoll()) {
document.getElementById('polldata').value = JSON.stringify(polldata);
}
}

document.addEventListener("DOMContentLoaded", function(event) {
pollReset();

// scroll to bottom
var div = document.getElementById("main");
div.scrollTop = div.scrollHeight;

// focus the text input
const input = document.getElementById('post');
input.focus();
input.selectionStart = input.selectionEnd = input.value.length;

let togglepollButton = document.getElementById('togglepoll');
togglepollButton.onclick = function() {
let div = document.getElementById('polldesigner');
if (div.style.display !== 'none') {
div.style.display = 'none';
pollReset();
updatePollHTML(true);
} else {
pollReset();
updatePollHTML(false);
div.style.display = 'block';
}
};
});
</script>
22 changes: 22 additions & 0 deletions design/partials/note.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@
<p><a href="{{note.inReplyTo}}" class="showThread">Show Thread</a></p>
{{/if}}

{{#if (isEq note.type 'Question')}}
<form id="{{note.id}}">
<fieldset>
{{#each note.oneOf}}
<input type="radio" id="poll_option{{@index}}" name="pollchoice" value="{{name}}">
<label for="{{@index}}">{{name}}</label> ({{replies.totalItems}})<br>
{{/each}}
{{#each note.anyOf}}
<input type="checkbox" id="poll_option{{@index}}" name="pollchoice" value="{{name}}">
<label for="{{@index}}">{{name}}</label> ({{replies.totalItems}})<br>
{{/each}}
</fieldset>
</form>
{{#if (expired note.endTime)}}
Ended {{timesince note.endTime}}
{{else}}
Ends {{timesince note.endTime}}
{{/if}}
<br>
Votes cast: {{note.votersCount}}<br>
{{/if}}

{{#each note.attachment}}
<div class="attachment">
{{#isImage mediaType}}
Expand Down
21 changes: 21 additions & 0 deletions design/public/note.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@
{{/isVideo}}
</div>
{{/each}}
{{#if (isEq note.type 'Question')}}
<form id="{{note.id}}">
<fieldset>
{{#each note.oneOf}}
<input type="radio" id="poll_option{{@index}}" name="pollchoice" value="{{name}}" disabled>
<label for="{{@index}}">{{name}}</label> ({{replies.totalItems}})<br>
{{/each}}
{{#each note.anyOf}}
<input type="checkbox" id="poll_option{{@index}}" name="pollchoice" value="{{name}}" disabled>
<label for="{{@index}}">{{name}}</label> ({{replies.totalItems}})<br>
{{/each}}
</fieldset>
</form>
{{#if (expired note.endTime)}}
Ended {{timesince note.endTime}}
{{else}}
Ends {{timesince note.endTime}}
{{/if}}
<br>
Votes cast: {{note.votersCount}}<br>
{{/if}}
<footer>
<div class="tools">
{{#if stats}}
Expand Down
11 changes: 9 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,18 @@ const hbs = create({
if (str && str.includes('image')) return options.fn(this);
},
isEq: (a, b, options) => {
if (a === b) return options.fn(this);
if (typeof options.fn === 'function') {
if (a === b) return options.fn(this);
} else {
return a === b;
}
},
or: (a, b, options) => {
return a || b
},
expired: (date) => {
return moment(date).isBefore(moment())
},
timesince: (date) => {
return moment(date).fromNow();
},
Expand Down Expand Up @@ -155,4 +162,4 @@ ensureAccount(USERNAME, DOMAIN).then((myaccount) => {
http.createServer(app).listen(app.get('port'), function () {
console.log('Express server listening on port ' + app.get('port'));
});
});
});
59 changes: 57 additions & 2 deletions lib/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,29 @@ export const getLikes = () => {
return readJSONDictionary(likesFile);
}

export const recordVote = async(id, name, actor) => {
const noteFile = getFileName(id);
let note = await getNote(id);
note.votersCount++;
// TODO: record who voted for what and discount multiple votes
// TODO: prevent multiple votes from single actor to oneOf
if (note.anyOf) {
note.anyOf.forEach((ao) => {
if (ao.name === name) {
ao.replies.totalItems++;
}
});
}
if (note.oneOf) {
note.oneOf.forEach((ao) => {
if (ao.name === name) {
ao.replies.totalItems++;
}
});
}
writeJSONDictionary(noteFile, note);
}

export const getNote = async (id) => {
// const postFile = path.resolve('./', pathToPosts, guid + '.json');
const noteFile = getFileName(id);
Expand Down Expand Up @@ -335,7 +358,7 @@ export const sendToFollowers = async (object) => {

}

export const createNote = async (body, cw, inReplyTo, toUser) => {
export const createNote = async (body, cw, inReplyTo, name, toUser, polldata) => {
const publicAddress = "https://www.w3.org/ns/activitystreams#Public";

let d = new Date();
Expand Down Expand Up @@ -438,8 +461,19 @@ export const createNote = async (body, cw, inReplyTo, toUser) => {
const activityId = `https://${ DOMAIN }/m/${guid}`;
const url = `https://${ DOMAIN }/notes/${guid}`;
const object = {
"@context": [
"https://www.w3.org/ns/activitystreams", {
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"votersCount": "toot:votersCount"
}
],
"id": activityId,
"type": "Note",
"type": polldata ? "Question" : "Note",
"summary": cw || null,
"inReplyTo": inReplyTo,
'published': d.toISOString(),
Expand Down Expand Up @@ -467,6 +501,27 @@ export const createNote = async (body, cw, inReplyTo, toUser) => {
}
};

// construct anyOf/oneOf block in Question
if (polldata) { // posting a poll
object.endTime = new Date(new Date().getTime() + 1000 * polldata.time).toISOString();
object.votersCount = 0;
object[polldata.type] = [];
polldata.choices.forEach((ch) => {
object[polldata.type].push({
type: 'Note',
name: ch,
replies: {
type: 'Collection',
totalItems: 0
}
});
});
}

if (name) { // voting on a poll
object.name = name;
}

if (directMessage) {
acceptDM(object, to[0]);
} else {
Expand Down
Loading