diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index adfa4ea8..4e31812f 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -9,6 +9,7 @@ import {InfiniteScroller} from "./infiniteScroller.js"; import {SnowFlake} from "./snowflake.js"; import { channeljson, + creatPollJSON, embedjson, filejson, memberjson, @@ -3619,12 +3620,14 @@ class Channel extends SnowFlake { embeds = [], sticker_ids = [], nonce = undefined, + poll = undefined, }: { - attachments: Blob[]; - embeds: embedjson[]; - replyingto: Message | null; - sticker_ids: string[]; + attachments?: Blob[]; + embeds?: embedjson[]; + replyingto?: Message | null; + sticker_ids?: string[]; nonce?: string; + poll?: creatPollJSON; }, onRes = (_e: "Ok" | "NotOk") => {}, ) { @@ -3634,7 +3637,8 @@ class Channel extends SnowFlake { content.trim() === "" && attachments.length === 0 && embeds.length == 0 && - sticker_ids.length === 0 + sticker_ids.length === 0 && + !poll ) { return; } @@ -3717,6 +3721,7 @@ class Channel extends SnowFlake { message_reference: undefined, sticker_ids, embeds, + poll, }; if (replyjson) { body.message_reference = replyjson; @@ -3758,6 +3763,7 @@ class Channel extends SnowFlake { message_reference: undefined, sticker_ids, embeds, + poll, }; if (replyjson) { body.message_reference = replyjson; @@ -3974,4 +3980,3 @@ class Channel extends SnowFlake { } Channel.setupcontextmenu(); export {Channel}; - diff --git a/src/webpage/contextmenu.ts b/src/webpage/contextmenu.ts index 957be17b..979d1ea7 100644 --- a/src/webpage/contextmenu.ts +++ b/src/webpage/contextmenu.ts @@ -235,15 +235,17 @@ type contextCluster = [Contextmenu, X, Y]; class LayeredEvent extends CustomEvent { menus: contextCluster[]; primary?: contextCluster; - constructor(mouse: MouseEvent, menus: LayeredEvent["menus"]) { + side: "top" | "bottom"; + constructor(mouse: MouseEvent, menus: LayeredEvent["menus"], side: "top" | "bottom") { super("layered", {bubbles: true}); + this.side = side; this.menus = menus; queueMicrotask(() => { console.log(this); const pop = this.primary || menus.pop(); if (!pop) return; const [menu, addinfo, other] = pop; - menu.makemenu(mouse.clientX, mouse.clientY, addinfo, other, undefined, menus); + menu.makemenu(mouse.clientX, mouse.clientY, addinfo, other, undefined, menus, this.side); }); } } @@ -340,7 +342,11 @@ class Contextmenu { other: y, keep: boolean | HTMLElement = false, layered: LayeredEvent["menus"] = [], + side: "top" | "bottom" = "top", ) { + if (side === "bottom") { + y = y - window.innerHeight; + } const div = document.createElement("div"); div.classList.add("contextmenu", "flexttb"); const processed = new WeakSet>(); @@ -386,6 +392,7 @@ class Contextmenu { touchDrag: (x: number, y: number) => unknown = () => {}, touchEnd: (x: number, y: number) => unknown = () => {}, click: "right" | "left" = "right", + side: "top" | "bottom" = "top", ) { const func = (event: MouseEvent) => { const selectedText = window.getSelection(); @@ -406,7 +413,7 @@ class Contextmenu { } event.stopImmediatePropagation(); event.preventDefault(); - const layered = new LayeredEvent(event, []); + const layered = new LayeredEvent(event, [], side); obj.dispatchEvent(layered); }; obj.addEventListener("layered", (layered) => { diff --git a/src/webpage/index.ts b/src/webpage/index.ts index 4be6d409..b9bf317d 100644 --- a/src/webpage/index.ts +++ b/src/webpage/index.ts @@ -423,7 +423,17 @@ if (window.location.pathname.startsWith("/channels")) { e.preventDefault(); e.stopImmediatePropagation(); }; - (document.getElementById("upload") as HTMLElement).onclick = () => { + const umenu = new Contextmenu("upload"); + umenu.addButton( + I18n.makePoll(), + () => { + thisUser.makePoll(); + }, + { + visible: () => !!thisUser.channelfocus?.hasPermission("SEND_POLLS"), + }, + ); + umenu.addButton(I18n.upload(), () => { const input = document.createElement("input"); input.type = "file"; input.click(); @@ -441,7 +451,16 @@ if (window.location.pathname.startsWith("/channels")) { } } }; - }; + }); + umenu.bindContextmenu( + document.getElementById("upload")!, + undefined, + undefined, + undefined, + undefined, + "left", + "bottom", + ); const emojiTB = document.getElementById("emojiTB") as HTMLElement; emojiTB.onmousedown = (e) => e.stopImmediatePropagation(); emojiTB.onclick = (e) => { diff --git a/src/webpage/jsontypes.ts b/src/webpage/jsontypes.ts index 4949e78b..3cc9b632 100644 --- a/src/webpage/jsontypes.ts +++ b/src/webpage/jsontypes.ts @@ -448,6 +448,37 @@ type emojijson = { animated?: boolean; emoji?: string; }; +interface pollMedia { + text: string; + emoji?: emojijson; +} +export interface creatPollJSON { + question: pollMedia; + answers: { + id?: number; + poll_media: pollMedia; + }[]; + duration: number; + allow_multiselect?: boolean; +} +export interface polljson { + question: pollMedia; + answers: { + answer_id?: number; + poll_media: pollMedia; + }[]; + expiry: string; + allow_multiselect?: boolean; + layout_type: 1; + results?: { + is_finalized: boolean; + answer_counts: { + id: number; + count: number; + me_voted: boolean; + }[]; + }; +} type emojipjson = emojijson & { available: boolean; guild_id: string; @@ -835,6 +866,7 @@ type messagejson = { sticker_items: stickerJson[]; message_reference?: string; referenced_message?: messagejson; + poll?: polljson; }; export interface threadMetadata { @@ -939,6 +971,18 @@ type messageCreateJson = { s: number; t: "MESSAGE_CREATE"; }; +export type pollUpdateJson = { + op: 0; + d: { + user_id: string; + channel_id: string; + message_id: string; + guild_id?: string; + answer_id: number; + }; + s: number; + t: "MESSAGE_POLL_VOTE_ADD" | "MESSAGE_POLL_VOTE_REMOVE"; +}; export interface relationJson { id: string; type: 0 | 1 | 2 | 3 | 4; @@ -1018,6 +1062,7 @@ type wsjson = } | messageCreateJson | readyjson + | pollUpdateJson | { op: 11; s: undefined; diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index 096be624..482225c3 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -19,6 +19,7 @@ import { readyjson, startTypingjson, wsjson, + pollUpdateJson, } from "./jsontypes.js"; import {Member} from "./member.js"; import {Dialog, Form, FormError, Options, Settings} from "./settings.js"; @@ -773,6 +774,14 @@ class Localuser { this.conectionChange(); break; } + case "MESSAGE_POLL_VOTE_ADD": + case "MESSAGE_POLL_VOTE_REMOVE": { + const m = this.messages.get(temp.d.message_id); + m?.pollUpdate(temp); + const f = this.pollUpdateSubMap.get(temp.d.message_id); + f?.(temp); + break; + } case "MESSAGE_DELETE": { temp.d.guild_id ??= "@me"; const channel = this.channelids.get(temp.d.channel_id); @@ -4527,7 +4536,77 @@ class Localuser { const searchBox = document.getElementById("searchBox") as HTMLDivElement; searchBox.style.setProperty("--hint-text", JSON.stringify(I18n.search.search())); } + makePoll() { + const d = new Dialog(I18n.makePoll()); + const opt = d.options; + const q = opt.addTextInput(I18n.poll.question(), () => {}); + opt.addText(I18n.poll.answers()); + const ansField = document.createElement("div"); + ansField.classList.add("flexttb", "pollAnsM"); + const answers = ["", ""] as string[]; + const genAnswerField = () => { + ansField.textContent = ""; + for (let i = 0; i < answers.length; i++) { + const si = i; + const div = document.createElement("div"); + div.classList.add("flexltr"); + const input = document.createElement("input"); + input.type = "text"; + input.value = answers[i]; + input.onchange = () => { + answers[si] = input.value; + }; + + const del = document.createElement("span"); + del.classList.add("svg-delete", "svgicon"); + div.append(input, del); + del.onclick = () => { + answers.splice(si, 1); + genAnswerField(); + }; + ansField.append(div); + } + }; + genAnswerField(); + opt.addHTMLArea(ansField); + opt.addButtonInput("", I18n.poll.newAnswer(), () => { + answers.push(""); + genAnswerField(); + }); + const hours = [1, 4, 8, 24, 72, 168, 336] as const; + const h = opt.addSelect( + I18n.poll.duration(), + () => {}, + //@ts-ignore-error this is fine :P + hours.map((_) => I18n.poll.durCount[_ + ""]()), + { + defaultIndex: 3, + }, + ); + const c = opt.addCheckboxInput(I18n.poll.mult(), () => {}); + opt.addButtonInput("", I18n.submit(), () => { + const chan = this.channelfocus; + if (!chan) return; + chan.sendMessage("", { + poll: { + question: { + text: q.value, + }, + answers: answers.map((_) => ({poll_media: {text: _}})), + duration: hours[h.index] as number, + allow_multiselect: c.value, + }, + }); + d.hide(); + }); + d.show(); + } curSearch?: Symbol; + pollUpdateSubMap = new Map void>(); + subToPollUpdate(m: string, func: ((u: pollUpdateJson) => void) | null) { + if (func) this.pollUpdateSubMap.set(m, func); + else this.pollUpdateSubMap.delete(m); + } mSearch(query: string) { const searchy = Symbol("search"); this.curSearch = searchy; diff --git a/src/webpage/markdown.ts b/src/webpage/markdown.ts index e3dfb0fe..f96df6ac 100644 --- a/src/webpage/markdown.ts +++ b/src/webpage/markdown.ts @@ -968,7 +968,10 @@ class MarkDown { return span; } static relTime(date: Date, nextUpdate?: () => void): string { - const time = Date.now() - +date; + const r = Date.now() - +date; + if (isNaN(r)) return "NaN"; + const up = r < 0; + const time = Math.abs(r); let seconds = Math.round(time / 1000); const round = time % 1000; @@ -983,25 +986,32 @@ class MarkDown { const formatter = new Intl.RelativeTimeFormat(I18n.lang, {style: "short"}); if (years) { - if (nextUpdate) - setTimeout( - nextUpdate, - round + (seconds + (minutes + (hours + days * 24) * 60) * 60) * 1000, - ); - return formatter.format(-years, "year"); + if (nextUpdate) { + const ti = round + (seconds + (minutes + (hours + days * 24) * 60) * 60) * 1000; + setTimeout(nextUpdate, up ? 1000 * 60 * 60 * 24 * 365 - ti : ti); + } + return formatter.format(up ? years : -years, "year"); } else if (days) { - if (nextUpdate) - setTimeout(nextUpdate, round + (seconds + (minutes + hours * 60) * 60) * 1000); - return formatter.format(-days, "days"); + if (nextUpdate) { + const ti = round + (seconds + (minutes + hours * 60) * 60) * 1000; + setTimeout(nextUpdate, up ? 1000 * 60 * 60 * 24 - ti : ti); + } + return formatter.format(up ? days : -days, "days"); } else if (hours) { - if (nextUpdate) setTimeout(nextUpdate, round + (seconds + minutes * 60) * 1000); - return formatter.format(-hours, "hours"); + if (nextUpdate) { + const ti = round + (seconds + minutes * 60) * 1000; + setTimeout(nextUpdate, up ? 1000 * 60 * 60 - ti : ti); + } + return formatter.format(up ? hours : -hours, "hours"); } else if (minutes) { - if (nextUpdate) setTimeout(nextUpdate, round + seconds * 1000); - return formatter.format(-minutes, "minutes"); + if (nextUpdate) { + const ti = round + seconds * 1000; + setTimeout(nextUpdate, up ? 1000 * 60 - ti : ti); + } + return formatter.format(up ? minutes : -minutes, "minutes"); } else { - if (nextUpdate) setTimeout(nextUpdate, round); - return formatter.format(-seconds, "seconds"); + if (nextUpdate) setTimeout(nextUpdate, up ? 1000 - round : round); + return formatter.format(up ? seconds : -seconds, "seconds"); } } static unspoil(e: any): void { diff --git a/src/webpage/message.ts b/src/webpage/message.ts index 39d59280..d327ecc7 100644 --- a/src/webpage/message.ts +++ b/src/webpage/message.ts @@ -14,6 +14,8 @@ import { interactionEvents, memberjson, messagejson, + polljson, + pollUpdateJson, userjson, } from "./jsontypes.js"; import {Emoji} from "./emoji.js"; @@ -68,6 +70,7 @@ class Message extends SnowFlake { }[] = []; pinned!: boolean; flags: number = 0; + poll?: polljson; getTimeStamp() { return new Date(this.timestamp).getTime(); } @@ -253,6 +256,22 @@ class Message extends SnowFlake { color: "red", }, ); + Message.contextmenu.addButton( + () => I18n.message.endPoll(), + function (this: Message) { + this.confirmDeletePoll(); + }, + { + visible: function () { + return ( + this.author.id === this.localuser.user.id && + !!this.poll && + !this.poll.results?.is_finalized + ); + }, + color: "red", + }, + ); Message.contextmenu.addButton( () => I18n.message.report(), async function () { @@ -642,6 +661,22 @@ class Message extends SnowFlake { } console.log("deleted done"); } + pollUpdate(update: pollUpdateJson) { + if (!this.poll) return; + if (!this.poll.results) this.poll.results = {is_finalized: false, answer_counts: []}; + let ans = this.poll.results.answer_counts.find((_) => _.id === update.d.answer_id); + if (!ans) { + ans = {id: update.d.answer_id, count: 0, me_voted: false}; + this.poll.results.answer_counts.push(ans); + } + if (update.t === "MESSAGE_POLL_VOTE_ADD") { + ans.count++; + if (update.d.user_id === this.localuser.user.id) ans.me_voted = true; + } else { + ans.count--; + if (update.d.user_id === this.localuser.user.id) ans.me_voted = false; + } + } reactdiv!: WeakRef; blockedPropigate() { const previd = this.channel.idToPrev.get(this.id); @@ -792,7 +827,8 @@ class Message extends SnowFlake { } } } - if (this.message_reference && this.type !== 6 && this.type !== 18) { + + if (this.message_reference && this.type !== 6 && this.type !== 18 && this.type !== 46) { const replyline = document.createElement("div"); const minipfp = document.createElement("img"); @@ -1137,6 +1173,76 @@ class Message extends SnowFlake { time.classList.add("timestamp"); text.append(time); div.classList.add("topMessage"); + } else if (this.type === 46) { + build.classList.remove("flexltr"); + build.classList.add("flexttb"); + const t = I18n.message.pollRes("$$$$", "||||"); + const content = document.createElement("div"); + content.classList.add("flexltr", "pollRes"); + const username = document.createElement("span"); + this.author.bind(username, this.guild); + username.classList.add("username"); + const [before, after] = t.split("$$$$"); + username.textContent = this.author.name; + const [b1, a1] = before.split("||||") as [string, undefined | string]; + const [b2, a2] = after.split("||||") as [string, undefined | string]; + const b1s = document.createElement("span"); + b1s.textContent = b1; + content.append(b1s); + const poll = document.createElement("span"); + poll.classList.add("pollText", "username"); + poll.onclick = () => { + this.channel.focus(this.message_reference?.message_id!, true); + }; + this.channel.getmessage(this.message_reference?.message_id!).then((_) => { + if (!_) return; + poll.textContent = _!.poll!.question!.text; + }); + + if (a1) { + content.append(poll); + const a1s = document.createElement("span"); + a1s.textContent = a1; + content.append(a1s); + } + content.append(username); + const b2s = document.createElement("span"); + b2s.textContent = b2; + content.append(b2s); + if (a2) { + content.append(poll); + const a2s = document.createElement("span"); + a2s.textContent = a2; + content.append(a2s); + } + build.append(content); + + const resBody = document.createElement("div"); + resBody.classList.add("embed", "flexltr", "pollresembed"); + const res = document.createElement("div"); + res.classList.add("flexttb"); + const m = new Map((this.embeds[0].json.fields ?? []).map((f) => [f.name, f.value] as const)); + if (m.has("victor_answer_text")) { + const ans = document.createElement("span"); + ans.textContent = m.get("victor_answer_text") + ""; + const winning = document.createElement("span"); + const per = Number(m.get("victor_answer_votes")) / Number(m.get("total_votes")); + winning.textContent = I18n.poll.winningAnswer(Math.round(per * 100) + ""); + res.append(ans, winning); + } else { + res.textContent = I18n.poll.tie(); + } + const view = document.createElement("button"); + view.textContent = I18n.poll.view(); + view.onclick = () => { + this.channel.focus(this.message_reference?.message_id!, true); + }; + resBody.append(res, view); + + build.append(resBody); + + this.bindButtonEvent(); + return div; } build.appendChild(text); const stickerArea = document.createElement("div"); @@ -1145,6 +1251,118 @@ class Message extends SnowFlake { stickerArea.append(sticker.getHTML()); } div.append(stickerArea); + + if (this.poll) { + const pollbody = document.createElement("div"); + pollbody.classList.add("flexttb", "pollBody"); + let voted = false; + const genPoll = () => { + if (!this.poll) return; + pollbody.textContent = ""; + const d = new Date(this.poll.expiry); + const expired = +d < Date.now() || this.poll.results?.is_finalized || false; + const nupdate = () => { + fetch(`${this.info.api}/channels/${this.channel.id}/polls/${this.id}/answers/@me`, { + method: "PUT", + headers: this.headers, + body: JSON.stringify({ + answer_ids: [...r.values()] + .filter((_) => _.me_voted) + .map(({id}) => id) + .filter((_) => _), + }), + }); + voted = !!r.values.length; + }; + if (!this.poll.results) this.poll.results = {is_finalized: false, answer_counts: []}; + const r = new Map((this.poll.results?.answer_counts ?? []).map((_) => [_.id, _] as const)); + const question = document.createElement("h3"); + question.textContent = this.poll.question.text; + pollbody.append(question); + let ccount = [...r.values()].reduce((e, l) => e + +l.me_voted, 0); + if (this.poll.allow_multiselect) voted = !!ccount; + for (const a of this.poll.answers) { + const aarea = document.createElement("div"); + aarea.classList.add("flexltr", "answerArea"); + const span = document.createElement("span"); + span.textContent = a.poll_media.text; + const check = document.createElement("input"); + check.type = "checkbox"; + check.disabled = expired; + check.checked = !!r.get(a.answer_id ?? -1)?.me_voted; + + aarea.append(span, check); + if (!expired) + check.onclick = (e) => { + e.stopImmediatePropagation(); + if (ccount && check.checked && !this.poll?.allow_multiselect) { + check.checked = false; + return; + } else ccount += check.checked ? 1 : -1; + if (this.poll?.allow_multiselect && ccount) voted = true; + let g = r.get(a.answer_id ?? -1); + if (!g) { + g = {count: 0, id: a.answer_id ?? -1, me_voted: false}; + this.poll?.results?.answer_counts.push(g); + r.set(a.answer_id ?? -1, g); + } + g.me_voted = check.checked; + if (this.poll?.allow_multiselect) nupdate(); + }; + if (voted || expired) { + const total = [...r.values()].reduce((e, l) => e + l.count, 0); + const count = document.createElement("span"); + count.classList.add("countpollspan"); + let c = r.get(a.answer_id ?? -1)?.count ?? 0; + if (isNaN(c)) c = 0; + let per = Math.round((c / total) * 100); + if (isNaN(per)) per = 0; + count.textContent = I18n.poll.count("" + c, per + ""); + aarea.append(count); + if (per) + aarea.style.background = `linear-gradient(to right, var(--green) ${per}%, var(--bg) ${100 - per}%)`; + } + aarea.onclick = () => check.click(); + pollbody.append(aarea); + } + + if (!this.poll.allow_multiselect) { + const submit = document.createElement("button"); + submit.textContent = I18n.submit(); + pollbody.append(submit); + if (expired) { + submit.disabled = true; + } else { + submit.onclick = () => { + nupdate(); + }; + } + } + + if (expired) { + } else { + const expiresAt = document.createElement("span"); + const updatePollTime = async () => { + if (document.contains(expiresAt)) + expiresAt.textContent = I18n.poll.expires(MarkDown.relTime(d, updatePollTime)); + }; + expiresAt.textContent = I18n.poll.expires(MarkDown.relTime(d)); + queueMicrotask(() => MarkDown.relTime(d, updatePollTime)); + pollbody.append(expiresAt); + } + }; + genPoll(); + this.localuser.subToPollUpdate(this.id, () => { + if (document.contains(div)) { + genPoll(); + } else { + this.localuser.subToPollUpdate(this.id, null); + } + }); + + div.append(pollbody); + } + if (!dupe) { if (this.components && this.components.components.length) { const cdiv = this.components.getHTML(); @@ -1304,7 +1522,7 @@ class Message extends SnowFlake { }); }; } - if (this.author === this.localuser.user) { + if (this.author === this.localuser.user && this.type !== 46) { const container = document.createElement("button"); const edit = document.createElement("span"); edit.classList.add("svg-edit", "svgicon"); @@ -1353,6 +1571,22 @@ class Message extends SnowFlake { }); diaolog.show(); } + confirmDeletePoll() { + const diaolog = new Dialog(""); + diaolog.options.addTitle(I18n.deleteConfirmPoll()); + const options = diaolog.options.addOptions("", {ltr: true}); + options.addButtonInput("", I18n.yes(), () => { + fetch(`${this.info.api}/channels/${this.channel.id}/polls/${this.id}/expire`, { + headers: this.headers, + method: "POST", + }); + diaolog.hide(); + }); + options.addButtonInput("", I18n.no(), () => { + diaolog.hide(); + }); + diaolog.show(); + } updateReactions() { const reactdiv = this.reactdiv.deref(); if (!reactdiv) return; diff --git a/src/webpage/style.css b/src/webpage/style.css index e0e78835..9d9ebb0e 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -183,6 +183,9 @@ body { min-height: 0; display: flex; } +.pollRes { + padding-left: 50px; +} .amsCont { display: flex; flex-direction: row; @@ -257,6 +260,39 @@ body { display: flex; flex-direction: column; } +.pollBody { + background: #00000059; + padding: 10px; + border-radius: 4px; + margin-top: 2px; + margin-left: 52px; + h3 { + margin-bottom: 10px; + } +} +.answerArea { + --bg: #0000004a; + background: var(--bg); + margin-top: 3px; + padding: 12px; + cursor: pointer; + position: relative; + + input { + margin-left: auto; + cursor: pointer; + } +} +.pollAnsM { + .svg-delete { + width: 20px; + cursor: pointer; + margin-left: 6px; + } + .flexltr { + align-items: center; + } +} .reses { span { background: #0000007a; @@ -2733,6 +2769,9 @@ span.instanceStatus { position: relative; background-image: var(--userbg, linear-gradient(var(--primary-text), var(--primary-text))); } +.pollText { + margin: 0px 4px; +} .roleIcon { display: inline-block; padding: 1px; @@ -3057,6 +3096,17 @@ span .quote:last-of-type .quoteline { line-height: 20px; } } +.countpollspan { + position: absolute; + right: 40px; +} +.pollresembed { + margin-left: 50px; + margin-top: 6px; + button { + margin-left: auto; + } +} .linkembed { margin-top: 4px; } diff --git a/translations/en.json b/translations/en.json index f6138e7d..4611454b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -213,6 +213,7 @@ "createAccount": "Create Account", "delete": "Delete", "deleteConfirm": "Are you sure you want to delete this?", + "deleteConfirmPoll":"Are you sure you want to end the poll early?", "devSettings": { "badUser": "Enable logging of bad user objects that send too much information:", "cache": "Enable Service Worker Caching map files:", @@ -621,6 +622,7 @@ "andMore": "$1, and more!", "attached": "sent an attachment", "delete": "Delete message", + "endPoll":"End poll", "report":"Report message", "deleted": "Deleted message", "edit": "Edit message", @@ -635,7 +637,8 @@ "reactions": "View reactions", "reactionsTitle": "Reactions", "retry": "Resend errored message", - "viewrest": "View rest" + "viewrest": "View rest", + "pollRes":"$1's poll $2 has closed." }, "report":{ "back":"Back", @@ -818,6 +821,29 @@ "roleFileIcon": "Role icon:", "roles": "Roles" }, + "upload":"Upload files", + "makePoll":"Make poll", + "poll":{ + "question":"Question:", + "answers":"Answers:", + "newAnswer":"New Answer", + "duration":"Duration:", + "durCount":{ + "1":"1 hour", + "4":"4 hours", + "8":"8 hours", + "24":"24 hours", + "72":"3 days", + "168":"7 days", + "336":"14 days" + }, + "mult":"Allow multiple answers:", + "expires":"Expires: $1", + "tie":"It was a tie!", + "winningAnswer":"Winning answer: $1%", + "view":"View poll", + "count":"$1 {{PLURAL:$1|vote|votes}} $2%" + }, "search": { "back": "Back", "new": "New",