add poll support

This commit is contained in:
MathMan05
2026-05-21 16:12:34 -05:00
parent 56e9ef7732
commit 67e5561295
9 changed files with 499 additions and 30 deletions
+11 -6
View File
@@ -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};
+10 -3
View File
@@ -235,15 +235,17 @@ type contextCluster<X, Y> = [Contextmenu<X, Y>, X, Y];
class LayeredEvent extends CustomEvent<unknown> {
menus: contextCluster<unknown, unknown>[];
primary?: contextCluster<unknown, unknown>;
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<x, y> {
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<menuPart<unknown, unknown>>();
@@ -386,6 +392,7 @@ class Contextmenu<x, y> {
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<x, y> {
}
event.stopImmediatePropagation();
event.preventDefault();
const layered = new LayeredEvent(event, []);
const layered = new LayeredEvent(event, [], side);
obj.dispatchEvent(layered);
};
obj.addEventListener("layered", (layered) => {
+15 -2
View File
@@ -423,7 +423,11 @@ if (window.location.pathname.startsWith("/channels")) {
e.preventDefault();
e.stopImmediatePropagation();
};
(document.getElementById("upload") as HTMLElement).onclick = () => {
const umenu = new Contextmenu<void, void>("upload");
umenu.addButton(I18n.makePoll(), () => {
thisUser.makePoll();
});
umenu.addButton(I18n.upload(), () => {
const input = document.createElement("input");
input.type = "file";
input.click();
@@ -441,7 +445,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) => {
+45
View File
@@ -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;
+79
View File
@@ -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<string, (u: pollUpdateJson) => 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;
+26 -16
View File
@@ -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 {
+236 -2
View File
@@ -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<HTMLDivElement>;
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;
+50
View File
@@ -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;
}
+27 -1
View File
@@ -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",