From 8d9a8b62617ac21d0dbfc360bfc217b76611e31f Mon Sep 17 00:00:00 2001 From: morethanwords Date: Sat, 10 Oct 2020 05:08:12 +0300 Subject: [PATCH] Poll creator: Non-anonymous mode support; Multiple choice mode support; Quiz mode support; Fix revote rights; --- src/components/chat/contextMenu.ts | 10 +- src/components/checkbox.ts | 17 +++ src/components/popupCreatePoll.ts | 137 ++++++++++++++++++++-- src/components/radioField.ts | 18 +++ src/lib/appManagers/appMessagesManager.ts | 19 +-- src/lib/appManagers/appPollsManager.ts | 14 ++- src/scss/partials/popups/_createPoll.scss | 32 +++-- src/scss/style.scss | 134 +++++++++++++++++++-- 8 files changed, 335 insertions(+), 46 deletions(-) create mode 100644 src/components/checkbox.ts create mode 100644 src/components/radioField.ts diff --git a/src/components/chat/contextMenu.ts b/src/components/chat/contextMenu.ts index c9404070..c4f65b27 100644 --- a/src/components/chat/contextMenu.ts +++ b/src/components/chat/contextMenu.ts @@ -2,7 +2,7 @@ import appChatsManager from "../../lib/appManagers/appChatsManager"; import appImManager from "../../lib/appManagers/appImManager"; import appMessagesManager from "../../lib/appManagers/appMessagesManager"; import appPeersManager from "../../lib/appManagers/appPeersManager"; -import appPollsManager from "../../lib/appManagers/appPollsManager"; +import appPollsManager, { Poll } from "../../lib/appManagers/appPollsManager"; import $rootScope from "../../lib/rootScope"; import { findUpClassName } from "../../lib/utils"; import ButtonMenu, { ButtonMenuItemOptions } from "../buttonMenu"; @@ -67,7 +67,7 @@ export class ChatContextMenu { icon: 'edit', text: 'Edit', onClick: this.onEditClick, - verify: (peerID: number, msgID: number) => appMessagesManager.canEditMessage(msgID) + verify: (peerID: number, msgID: number) => appMessagesManager.canEditMessage(msgID, 'text') }, { icon: 'copy', text: 'Copy', @@ -84,8 +84,8 @@ export class ChatContextMenu { onClick: this.onRetractVote, verify: (peerID: number, msgID) => { const message = appMessagesManager.getMessage(msgID); - const poll = message.media?.poll; - return poll && !poll.pFlags.closed; + const poll = message.media?.poll as Poll; + return poll && poll.chosenIndexes.length && !poll.pFlags.closed && !poll.pFlags.quiz; } }, { icon: 'lock', @@ -94,7 +94,7 @@ export class ChatContextMenu { verify: (peerID: number, msgID) => { const message = appMessagesManager.getMessage(msgID); const poll = message.media?.poll; - return appMessagesManager.canEditMessage(msgID) && message.fromID == $rootScope.myID && message.fwd_from === undefined && poll && !poll.pFlags.closed; + return appMessagesManager.canEditMessage(msgID, 'poll') && poll && !poll.pFlags.closed; } }, { icon: 'forward', diff --git a/src/components/checkbox.ts b/src/components/checkbox.ts new file mode 100644 index 00000000..de7aca4f --- /dev/null +++ b/src/components/checkbox.ts @@ -0,0 +1,17 @@ +const CheckboxField = (text: string, name: string) => { + const label = document.createElement('label'); + label.classList.add('checkbox-field'); + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = 'input-' + name; + + const span = document.createElement('span'); + span.innerText = text; + + label.append(input, span); + + return {label, input, span}; +}; + +export default CheckboxField; \ No newline at end of file diff --git a/src/components/popupCreatePoll.ts b/src/components/popupCreatePoll.ts index 011a136d..8064d18c 100644 --- a/src/components/popupCreatePoll.ts +++ b/src/components/popupCreatePoll.ts @@ -1,7 +1,11 @@ import appMessagesManager from "../lib/appManagers/appMessagesManager"; +import appPeersManager from "../lib/appManagers/appPeersManager"; import appPollsManager, { Poll } from "../lib/appManagers/appPollsManager"; import $rootScope from "../lib/rootScope"; +import { findUpTag, whichChild } from "../lib/utils"; +import CheckboxField from "./checkbox"; import { PopupElement } from "./popup"; +import RadioField from "./radioField"; import Scrollable from "./scrollable"; import { toast } from "./toast"; @@ -23,6 +27,13 @@ export default class PopupCreatePoll extends PopupElement { private scrollable: Scrollable; private tempID = 0; + private anonymousCheckboxField: ReturnType; + private multipleCheckboxField: PopupCreatePoll['anonymousCheckboxField']; + private quizCheckboxField: PopupCreatePoll['anonymousCheckboxField']; + + private correctAnswers: Uint8Array[]; + private quizSolutionInput: HTMLInputElement; + constructor() { super('popup-create-poll popup-new-media', null, {closable: true, withConfirm: 'CREATE', body: true}); @@ -38,11 +49,71 @@ export default class PopupCreatePoll extends PopupElement { d.classList.add('caption'); d.innerText = 'Options'; - this.questions = document.createElement('div'); + this.questions = document.createElement('form'); this.questions.classList.add('poll-create-questions'); + const dd = document.createElement('div'); + dd.classList.add('poll-create-settings'); + + const settingsCaption = document.createElement('div'); + settingsCaption.classList.add('caption'); + settingsCaption.innerText = 'Settings'; + + const peerID = $rootScope.selectedPeerID; + + if(!appPeersManager.isBroadcast(peerID)) { + this.anonymousCheckboxField = CheckboxField('Anonymous Voting', 'anonymous'); + this.anonymousCheckboxField.input.checked = true; + dd.append(this.anonymousCheckboxField.label); + } + + this.multipleCheckboxField = CheckboxField('Multiple Answers', 'multiple'); + this.quizCheckboxField = CheckboxField('Quiz Mode', 'quiz'); + + this.multipleCheckboxField.input.addEventListener('change', () => { + const checked = this.multipleCheckboxField.input.checked; + this.quizCheckboxField.input.toggleAttribute('disabled', checked); + }); + + this.quizCheckboxField.input.addEventListener('change', () => { + const checked = this.quizCheckboxField.input.checked; + + (Array.from(this.questions.children) as HTMLElement[]).map(el => { + el.classList.toggle('radio-field', checked); + }); + + quizElements.forEach(el => el.classList.toggle('hide', !checked)); + + this.multipleCheckboxField.input.toggleAttribute('disabled', checked); + }); + + dd.append(this.multipleCheckboxField.label, this.quizCheckboxField.label); + + const quizElements: HTMLElement[] = []; + + const quizSolutionCaption = document.createElement('div'); + quizSolutionCaption.classList.add('caption'); + quizSolutionCaption.innerText = 'Explanation'; + + const quizHr = document.createElement('hr'); + + const quizSolutionContainer = document.createElement('div'); + quizSolutionContainer.classList.add('poll-create-questions'); + + const quizSolutionField = InputField('Add a Comment (Optional)', 'Add a Comment (Optional)', 'solution'); + this.quizSolutionInput = quizSolutionField.firstElementChild as HTMLInputElement; + + const quizSolutionSubtitle = document.createElement('div'); + quizSolutionSubtitle.classList.add('subtitle'); + quizSolutionSubtitle.innerText = 'Users will see this comment after choosing a wrong answer, good for educational purposes.'; + + quizSolutionContainer.append(quizSolutionField, quizSolutionSubtitle); + + quizElements.push(quizHr, quizSolutionCaption, quizSolutionContainer); + quizElements.forEach(el => el.classList.add('hide')); + this.body.parentElement.insertBefore(hr, this.body); - this.body.append(d, this.questions); + this.body.append(d, this.questions, document.createElement('hr'), settingsCaption, dd, ...quizElements); this.confirmBtn.addEventListener('click', this.onSubmitClick); @@ -54,17 +125,22 @@ export default class PopupCreatePoll extends PopupElement { const question = this.questionInput.value; if(!question.trim()) { - toast('Please enter a question'); + toast('Please enter a question.'); + return; + } + + if(this.quizCheckboxField.input.checked && !this.correctAnswers?.length) { + toast('Please choose the correct answer.'); return; } const answers = Array.from(this.questions.children).map((el, idx) => { - const input = (el.firstElementChild as HTMLInputElement); + const input = el.querySelector('input[type="text"]') as HTMLInputElement; return input.value; }).filter(v => !!v.trim()); if(answers.length < 2) { - toast('Please enter at least two options'); + toast('Please enter at least two options.'); return; } @@ -74,8 +150,23 @@ export default class PopupCreatePoll extends PopupElement { //const randomID = [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)]; //const randomIDS = bigint(randomID[0]).shiftLeft(32).add(bigint(randomID[1])).toString(); + const pFlags: Poll['pFlags'] = {}; + + if(this.anonymousCheckboxField && !this.anonymousCheckboxField.input.checked) { + pFlags.public_voters = true; + } + + if(this.multipleCheckboxField.input.checked) { + pFlags.multiple_choice = true; + } + + if(this.quizCheckboxField.input.checked) { + pFlags.quiz = true; + } + const poll: Poll = { _: 'poll', + pFlags, question, answers: answers.map((value, idx) => { return { @@ -88,17 +179,22 @@ export default class PopupCreatePoll extends PopupElement { }; //poll.id = randomIDS; - appMessagesManager.sendOther($rootScope.selectedPeerID, appPollsManager.getInputMediaPoll(poll)); + const inputMediaPoll = appPollsManager.getInputMediaPoll(poll, this.correctAnswers, this.quizSolutionInput ? this.quizSolutionInput.value : undefined); + + appMessagesManager.sendOther($rootScope.selectedPeerID, inputMediaPoll); }; onInput = (e: Event) => { const target = e.target as HTMLInputElement; + const radioLabel = findUpTag(target, 'LABEL'); if(target.value.length) { target.parentElement.classList.add('is-filled'); + radioLabel.classList.remove('hidden-widget'); + radioLabel.firstElementChild.removeAttribute('disabled'); } - const isLast = !target.parentElement.nextElementSibling; + const isLast = !radioLabel.nextElementSibling; if(isLast && target.value.length && this.questions.childElementCount < 10) { this.appendMoreField(); } @@ -106,27 +202,44 @@ export default class PopupCreatePoll extends PopupElement { onDeleteClick = (e: MouseEvent) => { const target = e.target as HTMLSpanElement; - target.parentElement.remove(); + findUpTag(target, 'LABEL').remove(); Array.from(this.questions.children).forEach((el, idx) => { - const label = el.firstElementChild.nextElementSibling as HTMLLabelElement; + const label = el.querySelector('label') as HTMLLabelElement; label.innerText = 'Option ' + (idx + 1); }); }; private appendMoreField() { + const tempID = this.tempID++; const idx = this.questions.childElementCount + 1; - const questionField = InputField('Add an Option', 'Option ' + idx, 'question-' + this.tempID++); + const questionField = InputField('Add an Option', 'Option ' + idx, 'question-' + tempID); (questionField.firstElementChild as HTMLInputElement).addEventListener('input', this.onInput); + const radioField = RadioField('', 'question'); + radioField.main.append(questionField); + radioField.label.classList.add('hidden-widget'); + radioField.input.disabled = true; + if(!this.quizCheckboxField.input.checked) { + radioField.label.classList.remove('radio-field'); + } + radioField.input.addEventListener('change', () => { + const checked = radioField.input.checked; + if(checked) { + const idx = whichChild(radioField.label); + this.correctAnswers = [new Uint8Array([idx])]; + } + }); + const deleteBtn = document.createElement('span'); deleteBtn.classList.add('btn-icon', 'tgico-close'); questionField.append(deleteBtn); deleteBtn.addEventListener('click', this.onDeleteClick, {once: true}); - this.questions.append(questionField); + this.questions.append(radioField.label); - this.scrollable.scrollTo(this.scrollable.scrollHeight, 'top', true, true); + this.scrollable.scrollIntoView(this.questions.lastElementChild as HTMLElement, true); + //this.scrollable.scrollTo(this.scrollable.scrollHeight, 'top', true, true); } } \ No newline at end of file diff --git a/src/components/radioField.ts b/src/components/radioField.ts new file mode 100644 index 00000000..2852a68a --- /dev/null +++ b/src/components/radioField.ts @@ -0,0 +1,18 @@ +const RadioField = (text: string, name: string) => { + const label = document.createElement('label'); + label.classList.add('radio-field'); + + const input = document.createElement('input'); + input.type = 'radio'; + input.id = input.name = 'input-radio-' + name; + + const main = document.createElement('div'); + main.classList.add('radio-field-main'); + main.innerText = text; + + label.append(input, main); + + return {label, input, main}; +}; + +export default RadioField; \ No newline at end of file diff --git a/src/lib/appManagers/appMessagesManager.ts b/src/lib/appManagers/appMessagesManager.ts index c6f40fbb..e055db96 100644 --- a/src/lib/appManagers/appMessagesManager.ts +++ b/src/lib/appManagers/appMessagesManager.ts @@ -667,9 +667,9 @@ export class AppMessagesManager { noWebPage: true, newMedia: any }> = {}) { - if(!this.canEditMessage(messageID)) { + /* if(!this.canEditMessage(messageID)) { return Promise.reject({type: 'MESSAGE_EDIT_FORBIDDEN'}); - } + } */ if(messageID < 0) { if(this.tempFinalizeCallbacks[messageID] === undefined) { @@ -2397,8 +2397,6 @@ export class AppMessagesManager { apiMessage.totalEntities = RichTextProcessor.mergeEntities(myEntities, apiEntities, !apiMessage.pending); } - apiMessage.canBeEdited = this.canMessageBeEdited(apiMessage); - if(!options.isEdited) { this.messagesStorage[mid] = apiMessage; (this.messagesStorageByPeerID[peerID] ?? (this.messagesStorageByPeerID[peerID] = {}))[mid] = apiMessage; @@ -2588,15 +2586,18 @@ export class AppMessagesManager { } } - public canMessageBeEdited(message: any) { + public canMessageBeEdited(message: any, kind: 'text' | 'poll') { const goodMedias = [ 'messageMediaPhoto', 'messageMediaDocument', 'messageMediaWebPage', - 'messageMediaPending', - 'messageMediaPoll' + 'messageMediaPending' ]; + if(kind == 'poll') { + goodMedias.push('messageMediaPoll'); + } + if(message._ != 'message' || message.deleted || message.fwd_from || @@ -2615,13 +2616,13 @@ export class AppMessagesManager { return true; } - public canEditMessage(messageID: number) { + public canEditMessage(messageID: number, kind: 'text' | 'poll' = 'text') { if(!this.messagesStorage[messageID]) { return false; } const message = this.messagesStorage[messageID]; - if(!message || !message.canBeEdited) { + if(!message || !this.canMessageBeEdited(message, kind)) { return false; } diff --git a/src/lib/appManagers/appPollsManager.ts b/src/lib/appManagers/appPollsManager.ts index b763a70f..64614f9f 100644 --- a/src/lib/appManagers/appPollsManager.ts +++ b/src/lib/appManagers/appPollsManager.ts @@ -1,3 +1,4 @@ +import { InputMedia } from "../../layer"; import { logger, LogLevels } from "../logger"; import apiManager from "../mtproto/mtprotoworker"; import { MOUNT_CLASS_TO } from "../mtproto/mtproto_config"; @@ -140,10 +141,19 @@ class AppPollsManager { }; } - public getInputMediaPoll(poll: Poll) { + public getInputMediaPoll(poll: Poll, correctAnswers?: Uint8Array[], solution?: string): InputMedia.inputMediaPoll { + let solution_entities: any[]; + if(solution) { + solution_entities = []; + solution = RichTextProcessor.parseMarkdown(solution, solution_entities); + } + return { _: 'inputMediaPoll', - poll + poll, + correct_answers: correctAnswers, + solution, + solution_entities }; } diff --git a/src/scss/partials/popups/_createPoll.scss b/src/scss/partials/popups/_createPoll.scss index a7ca3726..2e1561f3 100644 --- a/src/scss/partials/popups/_createPoll.scss +++ b/src/scss/partials/popups/_createPoll.scss @@ -8,6 +8,17 @@ } } + .radio-field { + margin: 0; + } + + .hidden-widget, .radio-field:first-child:last-child { + .btn-icon { + pointer-events: none; + opacity: 0 !important; + } + } + .input-field { margin-top: 25px; .btn-icon { @@ -19,13 +30,6 @@ opacity: 1; transition: opacity .2s ease; } - - &:not(.is-filled), &:first-child:last-child { - .btn-icon { - pointer-events: none; - opacity: 0; - } - } /* &:last-child:not(:nth-child(10)) { .btn-icon { display: none; @@ -36,14 +40,24 @@ .caption { color: #707579; font-weight: 500; - padding: 16px 24px 0; + padding: 1rem 1.5rem 0; } .poll-create-questions { - padding: 0px 20px 32.5px; + padding: 0px 1.25rem 2.03125rem; + } + + .poll-create-settings { + padding: 0 .5rem .5rem; } hr { border-bottom: 1px solid #edeff1; } + + .subtitle { + margin-top: .875rem; + font-size: .875rem; + line-height: 1.2; + } } \ No newline at end of file diff --git a/src/scss/style.scss b/src/scss/style.scss index ea986b3f..6613b2f0 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -72,6 +72,7 @@ $floating-left-sidebar: 925px; } :root { + --z-below: -1; --color-gray: #c4c9cc; --color-gray-hover: rgba(112, 117, 121, .08); --layer-transition: .2s ease-in-out; @@ -675,7 +676,7 @@ hr { margin: 1.25rem 0; display: block; text-align: left; - padding: 0 18px; + padding: 0 1.125rem; /* font-weight: 500; */ position: relative; @@ -684,23 +685,138 @@ hr { } } -[type="checkbox"] { - position: absolute; - opacity: 0; - pointer-events: none; - -webkit-box-sizing: border-box; +.radio-field { + display: block; + position: relative; + padding-left: 3.5rem; + text-align: left; + margin: 1.25rem 0; + line-height: 1.5rem; + cursor: pointer; + + &.hidden-widget { + cursor: default; + + .radio-field-main { + &::before, &::after { + visibility: hidden; + } + } + } + + > input { + &:checked { + & ~ .radio-field-main { + &::before { + border-color: $button-primary-background; + } + + &::after { + opacity: 1; + } + } + } + } + + .radio-field-main { + &::before, &::after { + content: ''; + display: block; + position: absolute; + left: .25rem; + top: 50%; + width: 1.25rem; + height: 1.25rem; + transform: translateY(-50%); + } + + &::before { + border: 2px solid #8d969c; + border-radius: 50%; + background-color: white; + opacity: 1; + transition: border-color .1s ease, opacity .1s ease; + } + + &::after { + left: .5625rem; + width: .625rem; + height: .625rem; + border-radius: 50%; + background: $button-primary-background; + opacity: 0; + transition: opacity .1s ease; + } + + /* .label { + display: block; + word-break: break-word; + } + + .subLabel { + display: block; + font-size: 0.875rem; + line-height: 1rem; + color: var(--color-text-secondary); + } */ + } +} + +[type="checkbox"], [type="radio"] { box-sizing: border-box; padding: 0; - + opacity: 0; + z-index: var(--z-below); + position: absolute; +} + +[type="checkbox"] { & + span { position: relative; - padding-left: calc(18px + 2.25rem); + padding-left: 3.5rem; cursor: pointer; display: inline-block; height: 25px; line-height: 25px; user-select: none; - + transition: .2s opacity; + } + + &:not(:checked) + span:before { + width: 0; + height: 0; + border: 2px solid transparent; + left: 6px; + top: 10px; + transform: rotateZ(45deg); + transform-origin: 100% 100%; + } + + &:checked + span:before { + top: 4px; + left: -1px; + width: 8px; + height: 14px; + border-top: 2px solid transparent; + border-left: 2px solid transparent; + border-right: 2px solid #fff; + border-bottom: 2px solid #fff; + transform: rotateZ(45deg); + transform-origin: 100% 100%; + } + + &:not(:checked) + span:after { + background-color: transparent; + border-color: #8d969c; + } + + &:checked + span:after { + background-color: $button-primary-background; + } +} + +[type="checkbox"] { + & + span { &:before, &:after { content: ''; left: 0;