Spaces:
Paused
Paused
| /* | |
| * Poll chat plugin | |
| * By bumbadadabum and Zarel. | |
| */ | |
| import { Utils } from '../../lib'; | |
| const MINUTES = 60000; | |
| const MAX_QUESTIONS = 10; | |
| interface PollAnswer { | |
| name: string; votes: number; correct?: boolean; | |
| } | |
| export interface PollOptions { | |
| activityNumber?: number; | |
| question: string; | |
| supportHTML: boolean; | |
| multiPoll: boolean; | |
| pendingVotes?: { [userid: string]: number[] }; | |
| voters?: { [k: string]: number[] }; | |
| voterIps?: { [k: string]: number[] }; | |
| maxVotes?: number; | |
| totalVotes?: number; | |
| timeoutMins?: number; | |
| timerEnd?: number; | |
| isQuiz?: boolean; | |
| answers: string[] | PollAnswer[]; | |
| } | |
| export interface PollData extends PollOptions { | |
| readonly activityid: 'poll'; | |
| } | |
| export class Poll extends Rooms.MinorActivity { | |
| readonly activityid = 'poll' as ID; | |
| name = "Poll"; | |
| activityNumber: number; | |
| question: string; | |
| multiPoll: boolean; | |
| pendingVotes: { [userid: string]: number[] }; | |
| voters: { [k: string]: number[] }; | |
| voterIps: { [k: string]: number[] }; | |
| totalVotes: number; | |
| isQuiz: boolean; | |
| /** Max votes of 0 means no vote cap */ | |
| maxVotes: number; | |
| answers: Map<number, PollAnswer>; | |
| constructor(room: Room, options: PollOptions) { | |
| super(room); | |
| this.activityNumber = options.activityNumber || room.nextGameNumber(); | |
| this.question = options.question; | |
| this.supportHTML = options.supportHTML; | |
| this.multiPoll = options.multiPoll; | |
| this.pendingVotes = options.pendingVotes || {}; | |
| this.voters = options.voters || {}; | |
| this.voterIps = options.voterIps || {}; | |
| this.totalVotes = options.totalVotes || 0; | |
| this.maxVotes = options.maxVotes || 0; | |
| // backwards compatibility | |
| if (!options.answers) options.answers = (options as any).questions; | |
| this.answers = Poll.getAnswers(options.answers); | |
| this.isQuiz = options.isQuiz ?? [...this.answers.values()].some(answer => answer.correct); | |
| this.setTimer(options); | |
| } | |
| select(user: User, option: number) { | |
| const userid = user.id; | |
| if (!this.multiPoll) { | |
| // vote immediately | |
| this.pendingVotes[userid] = [option]; | |
| this.submit(user); | |
| return; | |
| } | |
| if (!this.pendingVotes[userid]) { | |
| this.pendingVotes[userid] = []; | |
| } | |
| if (this.pendingVotes[userid].includes(option)) { | |
| throw new Chat.ErrorMessage(this.room.tr`That option is already selected.`); | |
| } | |
| this.pendingVotes[userid].push(option); | |
| this.updateFor(user); | |
| this.save(); | |
| } | |
| deselect(user: User, option: number) { | |
| const userid = user.id; | |
| const pendingVote = this.pendingVotes[userid]; | |
| if (!pendingVote?.includes(option)) { | |
| throw new Chat.ErrorMessage(this.room.tr`That option is not selected.`); | |
| } | |
| pendingVote.splice(pendingVote.indexOf(option), 1); | |
| this.updateFor(user); | |
| this.save(); | |
| } | |
| submit(user: User) { | |
| const ip = user.latestIp; | |
| const userid = user.id; | |
| if (userid in this.voters || (!Config.noipchecks && ip in this.voterIps)) { | |
| delete this.pendingVotes[userid]; | |
| throw new Chat.ErrorMessage(this.room.tr`You have already voted for this poll.`); | |
| } | |
| const selected = this.pendingVotes[userid]; | |
| if (!selected) throw new Chat.ErrorMessage(this.room.tr`No options selected.`); | |
| this.voters[userid] = selected; | |
| this.voterIps[ip] = selected; | |
| for (const option of selected) { | |
| this.answers.get(option)!.votes++; | |
| } | |
| delete this.pendingVotes[userid]; | |
| this.totalVotes++; | |
| if (this.maxVotes && this.totalVotes >= this.maxVotes) { | |
| this.end(this.room); | |
| return this.room | |
| .add(`|c|~|/log The poll hit the max vote cap of ${this.maxVotes}, and has ended.`) | |
| .update(); | |
| } | |
| this.update(); | |
| this.save(); | |
| } | |
| blankvote(user: User) { | |
| const ip = user.latestIp; | |
| const userid = user.id; | |
| if (!(userid in this.voters) || !(ip in this.voterIps)) { | |
| this.voters[userid] = []; | |
| this.voterIps[ip] = []; | |
| } | |
| this.updateTo(user); | |
| this.save(); | |
| } | |
| generateVotes(user: User | null) { | |
| const iconText = this.isQuiz ? | |
| `<i class="fa fa-question"></i> ${this.room.tr`Quiz`}` : | |
| `<i class="fa fa-bar-chart"></i> ${this.room.tr`Poll`}`; | |
| let output = `<div class="infobox"><p style="margin: 2px 0 5px 0"><span style="border:1px solid #6A6;color:#484;border-radius:4px;padding:0 3px">${iconText}</span>`; | |
| output += ` <strong style="font-size:11pt">${Poll.getQuestionMarkup(this.question, this.supportHTML)}</strong></p>`; | |
| if (this.multiPoll) { | |
| const empty = `<i class="fa fa-square-o" aria-hidden="true"></i>`; | |
| const chosen = `<i class="fa fa-check-square-o" aria-hidden="true"></i>`; | |
| const pendingVotes = (user && this.pendingVotes[user.id]) || []; | |
| for (const [num, answer] of this.answers) { | |
| const selected = pendingVotes.includes(num); | |
| output += `<div style="margin-top: 5px"><button style="text-align: left; border: none; background: none; color: inherit;" value="/poll ${selected ? 'de' : ''}select ${num}" name="send" title="${selected ? "Deselect" : "Select"} ${num}. ${Utils.escapeHTML(answer.name)}">${selected ? "<strong>" : ''}${selected ? chosen : empty} ${num}. `; | |
| output += `${Poll.getAnswerMarkup(answer, this.supportHTML)}${selected ? "</strong>" : ''}</button></div>`; | |
| } | |
| const submitButton = pendingVotes.length ? ( | |
| `<button class="button" value="/poll submit" name="send" title="${this.room.tr`Submit your vote`}"><strong>${this.room.tr`Submit`}</strong></button>` | |
| ) : ( | |
| `<button class="button" value="/poll results" name="send" title="${this.room.tr`View results`} - ${this.room.tr`you will not be able to vote after viewing results`}">(${this.room.tr`View results`})</button>` | |
| ); | |
| output += `<div style="margin-top: 7px; padding-left: 12px">${submitButton}</div>`; | |
| output += `</div>`; | |
| } else { | |
| for (const [num, answer] of this.answers) { | |
| output += `<div style="margin-top: 5px"><button class="button" style="text-align: left" value="/poll vote ${num}" name="send" title="${this.room.tr`Vote for ${num}`}. ${Utils.escapeHTML(answer.name)}">${num}.`; | |
| output += ` <strong>${Poll.getAnswerMarkup(answer, this.supportHTML)}</strong></button></div>`; | |
| } | |
| output += `<div style="margin-top: 7px; padding-left: 12px"><button value="/poll results" name="send" title="${this.room.tr`View results`} - ${this.room.tr`you will not be able to vote after viewing results`}"><small>(${this.room.tr`View results`})</small></button></div>`; | |
| output += `</div>`; | |
| } | |
| return output; | |
| } | |
| static generateResults( | |
| options: Rooms.MinorActivityData, room: Room, | |
| ended = false, choice: number[] | null = null | |
| ) { | |
| const iconText = options.isQuiz ? | |
| `<i class="fa fa-question"></i> ${room.tr`Quiz`}` : | |
| `<i class="fa fa-bar-chart"></i> ${room.tr`Poll`}`; | |
| const icon = `<span style="border:1px solid #${ended ? '777;color:#555' : '6A6;color:#484'};border-radius:4px;padding:0 3px">${iconText}${ended ? ' ' + room.tr`ended` : ""}</span> <small>${options.totalVotes || 0} ${room.tr`votes`}</small>`; | |
| let output = `<div class="infobox"><p style="margin: 2px 0 5px 0">${icon} <strong style="font-size:11pt">${this.getQuestionMarkup(options.question, options.supportHTML)}</strong></p>`; | |
| const answers = Poll.getAnswers(options.answers); | |
| // indigo, blue, green | |
| // nums start at 1 so the actual order is 1. blue, 2. green, 3. indigo, 4. blue | |
| const colors = ['#88B', '#79A', '#8A8']; | |
| for (const [num, answer] of answers) { | |
| const chosen = choice?.includes(num); | |
| const percentage = Math.round((answer.votes * 100) / (options.totalVotes || 1)); | |
| const answerMarkup = options.isQuiz ? | |
| `<span style="color:${answer.correct ? 'green' : 'red'};">${answer.correct ? '' : '<s>'}${this.getAnswerMarkup(answer, options.supportHTML)}${answer.correct ? '' : '</s>'}</span>` : | |
| this.getAnswerMarkup(answer, options.supportHTML); | |
| output += `<div style="margin-top: 3px">${num}. <strong>${chosen ? '<em>' : ''}${answerMarkup}${chosen ? '</em>' : ''}</strong> <small>(${answer.votes} vote${answer.votes === 1 ? '' : 's'})</small><br /><span style="font-size:7pt;background:${colors[num % 3]};padding-right:${percentage * 3}px"></span><small> ${percentage}%</small></div>`; | |
| } | |
| if (!choice && !ended) { | |
| output += `<div><small>(${room.tr`You can't vote after viewing results`})</small></div>`; | |
| } | |
| output += '</div>'; | |
| return output; | |
| } | |
| static getQuestionMarkup(question: string, supportHTML = false) { | |
| if (supportHTML) return question; | |
| return Chat.formatText(question); | |
| } | |
| static getAnswerMarkup(answer: PollAnswer, supportHTML = false) { | |
| if (supportHTML) return answer.name; | |
| return Chat.formatText(answer.name); | |
| } | |
| update() { | |
| const state = this.toJSON(); | |
| // Update the poll results for everyone that has voted | |
| const blankvote = Poll.generateResults(state, this.room, false); | |
| for (const id in this.room.users) { | |
| const user = this.room.users[id]; | |
| const selection = this.voters[user.id] || this.voterIps[user.latestIp]; | |
| if (selection) { | |
| if (selection.length) { | |
| user.sendTo( | |
| this.room, | |
| `|uhtmlchange|poll${this.activityNumber}|${Poll.generateResults(state, this.room, false, selection)}` | |
| ); | |
| } else { | |
| user.sendTo(this.room, `|uhtmlchange|poll${this.activityNumber}|${blankvote}`); | |
| } | |
| } | |
| } | |
| } | |
| updateTo(user: User, connection: Connection | null = null) { | |
| const state = this.toJSON(); | |
| const recipient = connection || user; | |
| const selection = this.voters[user.id] || this.voterIps[user.latestIp]; | |
| if (selection) { | |
| recipient.sendTo( | |
| this.room, | |
| `|uhtmlchange|poll${this.activityNumber}|${Poll.generateResults(state, this.room, false, selection)}` | |
| ); | |
| } else { | |
| recipient.sendTo(this.room, `|uhtmlchange|poll${this.activityNumber}|${this.generateVotes(user)}`); | |
| } | |
| } | |
| updateFor(user: User) { | |
| const state = this.toJSON(); | |
| if (user.id in this.voters) { | |
| user.sendTo( | |
| this.room, | |
| `|uhtmlchange|poll${this.activityNumber}|${Poll.generateResults(state, this.room, false, this.voters[user.id])}` | |
| ); | |
| } else { | |
| user.sendTo(this.room, `|uhtmlchange|poll${this.activityNumber}|${this.generateVotes(user)}`); | |
| } | |
| } | |
| display() { | |
| const state = this.toJSON(); | |
| const blankvote = Poll.generateResults(state, this.room, false); | |
| const blankquestions = this.generateVotes(null); | |
| for (const id in this.room.users) { | |
| const thisUser = this.room.users[id]; | |
| const selection = this.voters[thisUser.id] || this.voterIps[thisUser.latestIp]; | |
| if (selection) { | |
| if (selection.length) { | |
| thisUser.sendTo(this.room, | |
| `|uhtml|poll${this.activityNumber}|${Poll.generateResults(state, this.room, false, selection)}`); | |
| } else { | |
| thisUser.sendTo(this.room, `|uhtml|poll${this.activityNumber}|${blankvote}`); | |
| } | |
| } else { | |
| if (this.multiPoll && thisUser.id in this.pendingVotes) { | |
| thisUser.sendTo(this.room, `|uhtml|poll${this.activityNumber}|${this.generateVotes(thisUser)}`); | |
| } else { | |
| thisUser.sendTo(this.room, `|uhtml|poll${this.activityNumber}|${blankquestions}`); | |
| } | |
| } | |
| } | |
| } | |
| displayTo(user: User, connection: Connection | null = null) { | |
| const state = this.toJSON(); | |
| const recipient = connection || user; | |
| if (user.id in this.voters) { | |
| recipient.sendTo( | |
| this.room, | |
| `|uhtml|poll${this.activityNumber}|${Poll.generateResults(state, this.room, false, this.voters[user.id])}` | |
| ); | |
| } else if (user.latestIp in this.voterIps && !Config.noipchecks) { | |
| recipient.sendTo(this.room, `|uhtml|poll${this.activityNumber}|${Poll.generateResults( | |
| state, this.room, false, this.voterIps[user.latestIp] | |
| )}`); | |
| } else { | |
| recipient.sendTo(this.room, `|uhtml|poll${this.activityNumber}|${this.generateVotes(user)}`); | |
| } | |
| } | |
| onConnect(user: User, connection: Connection | null = null) { | |
| this.displayTo(user, connection); | |
| } | |
| onRename(user: User, oldid: ID, joining: boolean) { | |
| if (user.id in this.voters) { | |
| this.updateFor(user); | |
| } | |
| } | |
| destroy() { | |
| const results = Poll.generateResults(this.toJSON(), this.room, true); | |
| this.room.send(`|uhtmlchange|poll${this.activityNumber}|<div class="infobox">(${this.room.tr`The poll has ended – scroll down to see the results`})</div>`); | |
| this.room.add(`|html|${results}`).update(); | |
| this.room.setMinorActivity(null); | |
| } | |
| toJSON(): PollData { | |
| return { | |
| activityid: 'poll', | |
| activityNumber: this.activityNumber, | |
| question: this.question, | |
| supportHTML: this.supportHTML, | |
| multiPoll: this.multiPoll, | |
| pendingVotes: this.pendingVotes, | |
| voters: this.voters, | |
| voterIps: this.voterIps, | |
| totalVotes: this.totalVotes, | |
| timeoutMins: this.timeoutMins, | |
| timerEnd: this.timerEnd, | |
| isQuiz: this.isQuiz, | |
| answers: [...this.answers.values()], | |
| }; | |
| } | |
| save() { | |
| this.room.settings.minorActivity = this.toJSON(); | |
| this.room.saveSettings(); | |
| } | |
| static getAnswers(answers: string[] | PollAnswer[]) { | |
| const out = new Map<number, PollAnswer>(); | |
| if (answers.length && typeof answers[0] === 'string') { | |
| for (const [i, answer] of (answers as string[]).entries()) { | |
| out.set(i + 1, { | |
| name: answer.startsWith('+') ? answer.slice(1) : answer, | |
| votes: 0, | |
| correct: answer.startsWith('+'), | |
| }); | |
| } | |
| } else { | |
| for (const [i, answer] of (answers as PollAnswer[]).entries()) { | |
| out.set(i + 1, answer); | |
| } | |
| } | |
| return out; | |
| } | |
| } | |
| export const commands: Chat.ChatCommands = { | |
| poll: { | |
| htmlcreate: 'new', | |
| create: 'new', | |
| createmulti: 'new', | |
| htmlcreatemulti: 'new', | |
| queue: 'new', | |
| queuehtml: 'new', | |
| htmlqueue: 'new', | |
| queuemulti: 'new', | |
| htmlqueuemulti: 'new', | |
| new(target, room, user, connection, cmd, message) { | |
| room = this.requireRoom(); | |
| if (!target) return this.parse('/help poll new'); | |
| target = target.trim(); | |
| if (target.length > 1024) return this.errorReply(this.tr`Poll too long.`); | |
| if (room.battle) return this.errorReply(this.tr`Battles do not support polls.`); | |
| const text = this.filter(target); | |
| if (target !== text) return this.errorReply(this.tr`You are not allowed to use filtered words in polls.`); | |
| const supportHTML = cmd.includes('html'); | |
| const multiPoll = cmd.includes('multi'); | |
| const queue = cmd.includes('queue'); | |
| let params = []; | |
| let separator = ''; | |
| if (text.includes('\n')) { | |
| separator = '\n'; | |
| } else if (text.includes('|')) { | |
| separator = '|'; | |
| } else if (text.includes(',')) { | |
| separator = ','; | |
| } else { | |
| return this.errorReply(this.tr`Not enough arguments for /poll new.`); | |
| } | |
| let currentParam = ""; | |
| for (let i = 0; i < text.length; ++i) { | |
| const currentCharacter = text[i]; | |
| const nextCharacter = text[i + 1]; | |
| // If the current character is an escape character, insert the next character | |
| // into the param and then skip over checking it in our loop. | |
| const isEscapeCharacter = currentCharacter === '\\'; | |
| if (isEscapeCharacter) { | |
| if (nextCharacter) { | |
| currentParam += nextCharacter; | |
| i += 1; | |
| } else { | |
| return this.errorReply(this.tr`Extra escape character. To end a poll with '\\', enter it as '\\\\'`); | |
| } | |
| continue; | |
| } | |
| // At this point, we know this separator hasn't been escaped, so split here. | |
| const isSeparator = currentCharacter === separator; | |
| if (isSeparator) { | |
| params.push(currentParam); | |
| currentParam = ""; | |
| continue; | |
| } | |
| // The current character hasn't been escaped and isn't a separator, so it can just be added to the param. | |
| currentParam += currentCharacter; | |
| } | |
| // Be sure to get the last param we constructed into the array. | |
| params.push(currentParam); | |
| params = params.map(param => param.trim()); | |
| this.checkCan('minigame', null, room); | |
| if (supportHTML) this.checkCan('declare', null, room); | |
| this.checkChat(); | |
| if (room.minorActivity && !queue) { | |
| return this.errorReply(this.tr`There is already a poll or announcement in progress in this room.`); | |
| } | |
| if (params.length < 3) return this.errorReply(this.tr`Not enough arguments for /poll new.`); | |
| // the function throws on failure, so no handling needs to be done anymore | |
| if (supportHTML) params = params.map(parameter => this.checkHTML(parameter)); | |
| const questions = params.splice(1); | |
| if (questions.length > MAX_QUESTIONS) { | |
| return this.errorReply(this.tr`Too many options for poll (maximum is ${MAX_QUESTIONS}).`); | |
| } | |
| if (new Set(questions).size !== questions.length) { | |
| return this.errorReply(this.tr`There are duplicate options in the poll.`); | |
| } | |
| if (room.minorActivity) { | |
| room.queueMinorActivity({ | |
| question: params[0], answers: questions, multiPoll, supportHTML, activityid: 'poll', | |
| }); | |
| this.modlog('QUEUEPOLL'); | |
| return this.privateModAction(room.tr`${user.name} queued a poll.`); | |
| } | |
| room.setMinorActivity(new Poll(room, { | |
| question: params[0], supportHTML, answers: questions, multiPoll, | |
| })); | |
| this.roomlog(`${user.name} used ${message}`); | |
| this.modlog('POLL'); | |
| this.addModAction(room.tr`A poll was started by ${user.name}.`); | |
| }, | |
| newhelp: [ | |
| `/poll create [question], [option1], [option2], [...] - Creates a poll. Requires: % @ # ~`, | |
| `/poll createmulti [question], [option1], [option2], [...] - Creates a poll, allowing for multiple answers to be selected. Requires: % @ # ~`, | |
| `To queue a poll, use [queue], [queuemulti], [queuehtml], or [htmlqueuemulti].`, | |
| `Polls can be used as quiz questions. To do this, prepend all correct answers with a +.`, | |
| ], | |
| viewqueue(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('mute', null, room); | |
| this.parse(`/join view-pollqueue-${room.roomid}`); | |
| }, | |
| viewqueuehelp: [`/viewqueue - view the queue of polls in the room. Requires: % @ # ~`], | |
| deletequeue(target, room, user) { | |
| room = this.requireRoom(); | |
| if (!target) return this.parse('/help deletequeue'); | |
| this.checkCan('mute', null, room); | |
| const queue = room.getMinorActivityQueue(); | |
| if (!queue) { | |
| return this.errorReply(this.tr`The queue is already empty.`); | |
| } | |
| const slot = parseInt(target); | |
| if (isNaN(slot)) { | |
| return this.errorReply(this.tr`Can't delete poll at slot ${target} - "${target}" is not a number.`); | |
| } | |
| if (!queue[slot - 1]) return this.errorReply(this.tr`There is no poll in queue at slot ${slot}.`); | |
| room.clearMinorActivityQueue(slot - 1); | |
| room.modlog({ | |
| action: 'DELETEQUEUE', | |
| loggedBy: user.id, | |
| note: slot.toString(), | |
| }); | |
| room.sendMods(this.tr`(${user.name} deleted the queued poll in slot ${slot}.)`); | |
| room.update(); | |
| this.refreshPage(`pollqueue-${room.roomid}`); | |
| }, | |
| deletequeuehelp: [ | |
| `/poll deletequeue [number] - deletes poll at the corresponding queue slot (1 = next, 2 = the one after that, etc). Requires: % @ # ~`, | |
| ], | |
| clearqueue(target, room, user, connection, cmd) { | |
| room = this.requireRoom(); | |
| this.checkCan('mute', null, room); | |
| const queue = room.getMinorActivityQueue(); | |
| if (!queue) { | |
| return this.errorReply(this.tr`The queue is already empty.`); | |
| } | |
| room.clearMinorActivityQueue(); | |
| this.modlog('CLEARQUEUE'); | |
| this.sendReply(this.tr`Cleared poll queue.`); | |
| }, | |
| clearqueuehelp: [ | |
| `/poll clearqueue - deletes the queue of polls. Requires: % @ # ~`, | |
| ], | |
| deselect: 'select', | |
| vote: 'select', | |
| select(target, room, user, connection, cmd) { | |
| room = this.requireRoom(); | |
| const poll = this.requireMinorActivity(Poll); | |
| if (!target) return this.parse('/help poll vote'); | |
| const parsed = parseInt(target); | |
| if (isNaN(parsed)) return this.errorReply(this.tr`To vote, specify the number of the option.`); | |
| if (!poll.answers.has(parsed)) return this.sendReply(this.tr`Option not in poll.`); | |
| if (cmd === 'deselect') { | |
| poll.deselect(user, parsed); | |
| } else { | |
| poll.select(user, parsed); | |
| } | |
| }, | |
| selecthelp: [ | |
| `/poll select [number] - Select option [number].`, | |
| `/poll deselect [number] - Deselects option [number].`, | |
| ], | |
| submit(target, room, user) { | |
| room = this.requireRoom(); | |
| const poll = this.requireMinorActivity(Poll); | |
| poll.submit(user); | |
| }, | |
| submithelp: [`/poll submit - Submits your vote.`], | |
| timer(target, room, user) { | |
| room = this.requireRoom(); | |
| const poll = this.requireMinorActivity(Poll); | |
| if (target) { | |
| this.checkCan('minigame', null, room); | |
| if (target === 'clear') { | |
| if (!poll.endTimer()) return this.errorReply(this.tr("There is no timer to clear.")); | |
| return this.add(this.tr`The poll timer was turned off.`); | |
| } | |
| const timeoutMins = parseFloat(target); | |
| if (isNaN(timeoutMins) || timeoutMins <= 0 || timeoutMins > 7 * 24 * 60) { | |
| return this.errorReply(this.tr`Time should be a number of minutes less than one week.`); | |
| } | |
| poll.setTimer({ timeoutMins }); | |
| room.add(this.tr`The poll timer was turned on: the poll will end in ${Chat.toDurationString(timeoutMins * MINUTES)}.`); | |
| this.modlog('POLL TIMER', null, `${timeoutMins} minutes`); | |
| return this.privateModAction(room.tr`The poll timer was set to ${timeoutMins} minute(s) by ${user.name}.`); | |
| } else { | |
| if (!this.runBroadcast()) return; | |
| if (poll.timeout) { | |
| return this.sendReply(this.tr`The poll timer is on and will end in ${Chat.toDurationString(poll.timeoutMins * MINUTES)}.`); | |
| } else { | |
| return this.sendReply(this.tr`The poll timer is off.`); | |
| } | |
| } | |
| }, | |
| timerhelp: [ | |
| `/poll timer [minutes] - Sets the poll to automatically end after [minutes] minutes. Requires: % @ # ~`, | |
| `/poll timer clear - Clears the poll's timer. Requires: % @ # ~`, | |
| ], | |
| results(target, room, user) { | |
| room = this.requireRoom(); | |
| const poll = this.requireMinorActivity(Poll); | |
| poll.blankvote(user); | |
| }, | |
| resultshelp: [ | |
| `/poll results - Shows the results of the poll without voting. NOTE: you can't go back and vote after using this.`, | |
| ], | |
| close: 'end', | |
| stop: 'end', | |
| end(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('minigame', null, room); | |
| this.checkChat(); | |
| const poll = this.requireMinorActivity(Poll); | |
| this.modlog('POLL END'); | |
| this.privateModAction(room.tr`The poll was ended by ${user.name}.`); | |
| poll.end(room, Poll); | |
| }, | |
| endhelp: [`/poll end - Ends a poll and displays the results. Requires: % @ # ~`], | |
| show: '', | |
| display: '', | |
| ''(target, room, user, connection) { | |
| room = this.requireRoom(); | |
| const poll = this.requireMinorActivity(Poll); | |
| if (!this.runBroadcast()) return; | |
| room.update(); | |
| if (this.broadcasting) { | |
| poll.display(); | |
| } else { | |
| poll.displayTo(user, connection); | |
| } | |
| }, | |
| displayhelp: [`/poll display - Displays the poll`], | |
| mv: 'maxvotes', | |
| maxvotes(target, room, user) { | |
| room = this.requireRoom(); | |
| this.checkCan('mute', null, room); | |
| const poll = this.requireMinorActivity(Poll); | |
| let num = parseInt(target); | |
| if (this.meansNo(target)) { // special case for convenience | |
| num = 0; | |
| } | |
| if (isNaN(num)) { | |
| return this.errorReply(`Invalid max vote cap: '${target}'`); | |
| } | |
| if (poll.maxVotes === num) { | |
| return this.errorReply(`The poll's vote cap is already set to ${num}.`); | |
| } | |
| poll.maxVotes = num; | |
| this.addModAction(`${user.name} set the poll's vote cap to ${num}.`); | |
| let ended = false; | |
| if (poll.totalVotes > poll.maxVotes) { | |
| poll.end(room); | |
| this.addModAction(`The poll has more votes than the maximum vote cap, and has ended.`); | |
| ended = true; | |
| } | |
| if (!ended) poll.save(); | |
| this.modlog('POLL MAXVOTES', null, `${poll.maxVotes}${ended ? ` (ended poll)` : ""}`); | |
| }, | |
| }, | |
| pollhelp() { | |
| this.sendReply( | |
| `|html|<details class="readmore"><summary>/poll allows rooms to run their own polls (limit 1 at a time).<br />` + | |
| `Polls can be used as quiz questions, by putting <code>+</code> before correct answers.<br />` + | |
| `<code>/poll create [question], [option1], [option2], [...]</code> - Creates a poll. Requires: % @ # ~</summary>` + | |
| `<code>/poll createmulti [question], [option1], [option2], [...]</code> - Creates a poll, allowing for multiple answers to be selected. Requires: % @ # ~<br />` + | |
| `<code>/poll htmlcreate(multi) [question], [option1], [option2], [...]</code> - Creates a poll, with HTML allowed in the question and options. Requires: # ~<br />` + | |
| `<code>/poll vote [number]</code> - Votes for option [number].<br />` + | |
| `<code>/poll timer [minutes]</code> - Sets the poll to automatically end after [minutes]. Requires: % @ # ~.<br />` + | |
| `<code>/poll results</code> - Shows the results of the poll without voting. NOTE: you can't go back and vote after using this.<br />` + | |
| `<code>/poll display</code> - Displays the poll.<br />` + | |
| `<code>/poll end</code> - Ends a poll and displays the results. Requires: % @ # ~.<br />` + | |
| `<code>/poll queue [question], [option1], [option2], [...]</code> - Add a poll in queue. Requires: % @ # ~<br />` + | |
| `<code>/poll deletequeue [number]</code> - Deletes poll at the corresponding queue slot (1 = next, 2 = the one after that, etc).<br />` + | |
| `<code>/poll clearqueue</code> - Deletes the queue of polls. Requires: % @ # ~.<br />` + | |
| `<code>/poll viewqueue</code> - View the queue of polls in the room. Requires: % @ # ~<br />` + | |
| `<code>/poll maxvotes [number]</code> - Set the max poll votes to the given [number]. Requires: % @ # ~<br />` + | |
| `</details>` | |
| ); | |
| }, | |
| }; | |
| export const pages: Chat.PageTable = { | |
| pollqueue(args, user) { | |
| const room = this.requireRoom(); | |
| let buf = `<div class="pad"><strong>${this.tr`Queued polls:`}</strong>`; | |
| buf += `<button class="button" name="send" value="/join view-pollqueue-${room.roomid}" style="float: right">`; | |
| buf += `<i class="fa fa-refresh"></i> ${this.tr`Refresh`}</button><br />`; | |
| const queue = room.getMinorActivityQueue()?.filter(activity => activity.activityid === 'poll'); | |
| if (!queue) { | |
| buf += `<hr /><strong>${this.tr`No polls queued.`}</strong></div>`; | |
| return buf; | |
| } | |
| for (const [i, poll] of queue.entries()) { | |
| const number = i + 1; // for translation convienence | |
| const button = ( | |
| `<strong>${this.tr`#${number} in queue`} </strong>` + | |
| `<button class="button" name="send" value="/msgroom ${room.roomid},/poll deletequeue ${i + 1}">` + | |
| `(${this.tr`delete`})</button>` | |
| ); | |
| buf += `<hr />`; | |
| buf += `${button}<br />${Poll.generateResults(poll, room, false)}`; | |
| } | |
| buf += `<hr />`; | |
| return buf; | |
| }, | |
| }; | |
| process.nextTick(() => { | |
| Chat.multiLinePattern.register('/poll (new|create|createmulti|htmlcreate|htmlcreatemulti|queue|queuemulti|htmlqueuemulti) '); | |
| }); | |
| // convert from old format (should handle restarts and also hotpatches) | |
| for (const room of Rooms.rooms.values()) { | |
| if (room.getMinorActivityQueue(true)) { | |
| for (const poll of room.getMinorActivityQueue(true)!) { | |
| if (!poll.activityid) { | |
| // @ts-expect-error old format | |
| poll.activityid = poll.activityId; | |
| // @ts-expect-error old format | |
| delete poll.activityId; | |
| } | |
| if (!poll.activityNumber) { | |
| // @ts-expect-error old format | |
| poll.activityNumber = poll.pollNumber; | |
| // @ts-expect-error old format | |
| delete poll.pollNumber; | |
| } | |
| room.saveSettings(); | |
| } | |
| } | |
| if (room.settings.minorActivity) { | |
| if (!room.settings.minorActivity.activityid) { | |
| // @ts-expect-error old format | |
| room.settings.minorActivity.activityid = room.settings.minorActivity.activityId; | |
| // @ts-expect-error old format | |
| delete room.settings.minorActivity.activityId; | |
| } | |
| if (typeof room.settings.minorActivity.activityNumber !== 'number') { | |
| // @ts-expect-error old format | |
| room.settings.minorActivity.activityNumber = room.settings.minorActivity.pollNumber || | |
| // @ts-expect-error old format | |
| room.settings.minorActivity.announcementNumber; | |
| } | |
| room.saveSettings(); | |
| } | |
| if (room.settings.minorActivity?.activityid === 'poll') { | |
| room.setMinorActivity(new Poll(room, room.settings.minorActivity), true); | |
| } | |
| } | |