tweb/src/components/poll.ts

636 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import appPollsManager, { PollResults, Poll } from "../lib/appManagers/appPollsManager";
import { RichTextProcessor } from "../lib/richtextprocessor";
import { findUpClassName, $rootScope, cancelEvent } from "../lib/utils";
import { touchSupport } from "../lib/config";
import appSidebarRight from "../lib/appManagers/appSidebarRight";
import appImManager from "../lib/appManagers/appImManager";
import serverTimeManager from "../lib/mtproto/serverTimeManager";
import { ripple } from "./ripple";
import mediaSizes from "../helpers/mediaSizes";
let lineTotalLength = 0;
const tailLength = 9;
const times = 10;
const fullTime = 340;
const oneTime = fullTime / times;
export const roundPercents = (percents: number[]) => {
//console.log('roundPercents before percents:', percents);
const sum = percents.reduce((acc, p) => acc + Math.round(p), 0);
if(sum > 100) {
const diff = sum - 100;
const length = percents.length;
for(let i = 0; i < diff; ++i) {
let minIndex = -1, minRemainder = 1;
for(let k = 0; k < length; ++k) {
let remainder = percents[k] % 1;
if(remainder >= 0.5 && remainder < minRemainder) {
minRemainder = remainder;
minIndex = k;
}
}
if(minIndex == -1) {
//throw new Error('lol chto');
return;
}
percents[minIndex] -= minRemainder;
}
} else if(sum < 100) {
const diff = 100 - sum;
const length = percents.length;
for(let i = 0; i < diff; ++i) {
let minIndex = -1, maxRemainder = 0;
for(let k = 0; k < length; ++k) {
let remainder = percents[k] % 1;
if(remainder < 0.5 && remainder > maxRemainder) {
maxRemainder = remainder;
minIndex = k;
}
}
if(minIndex == -1) {
//throw new Error('lol chto');
return;
}
percents[minIndex] += 1 - maxRemainder;
}
}
//console.log('roundPercents after percents:', percents);
};
const connectedPolls: {id: string, element: PollElement}[] = [];
$rootScope.$on('poll_update', (e) => {
const {poll, results} = e.detail as {poll: Poll, results: PollResults};
//console.log('poll_update', poll, results);
for(const connected of connectedPolls) {
if(connected.id == poll.id) {
const pollElement = connected.element;
pollElement.isClosed = !!poll.pFlags.closed;
pollElement.performResults(results, poll.chosenIndexes);
}
}
});
$rootScope.$on('peer_changed', () => {
if(prevQuizHint) {
hideQuizHint(prevQuizHint, prevQuizHintOnHide, prevQuizHintTimeout);
}
});
const hideQuizHint = (element: HTMLElement, onHide: () => void, timeout: number) => {
element.classList.remove('active');
clearTimeout(timeout);
setTimeout(() => {
onHide();
element.remove();
if(prevQuizHint == element && prevQuizHintOnHide == onHide && prevQuizHintTimeout == timeout) {
prevQuizHint = prevQuizHintOnHide = null;
prevQuizHintTimeout = 0;
}
}, 200);
};
let prevQuizHint: HTMLElement, prevQuizHintOnHide: () => void, prevQuizHintTimeout: number;
const setQuizHint = (solution: string, solution_entities: any[], onHide: () => void) => {
if(prevQuizHint) {
hideQuizHint(prevQuizHint, prevQuizHintOnHide, prevQuizHintTimeout);
}
const element = document.createElement('div');
element.classList.add('quiz-hint');
const container = document.createElement('div');
container.classList.add('container', 'tgico');
const textEl = document.createElement('div');
textEl.classList.add('text');
container.append(textEl);
element.append(container);
textEl.innerHTML = RichTextProcessor.wrapRichText(solution, {entities: solution_entities});
appImManager.bubblesContainer.append(element);
void element.offsetLeft; // reflow
element.classList.add('active');
prevQuizHint = element;
prevQuizHintOnHide = onHide;
prevQuizHintTimeout = window.setTimeout(() => {
hideQuizHint(element, onHide, prevQuizHintTimeout);
}, touchSupport ? 5000 : 7000);
};
export default class PollElement extends HTMLElement {
private svgLines: SVGSVGElement[];
private numberDivs: HTMLDivElement[];
private answerDivs: HTMLDivElement[];
private descDiv: HTMLElement;
private typeDiv: HTMLElement;
private avatarsDiv: HTMLElement;
private viewResults: HTMLElement;
private votersCountDiv: HTMLDivElement;
private maxOffset = -46.5;
private maxLength: number;
private maxLengths: number[];
public isClosed = false;
private isQuiz = false;
private isRetracted = false;
private isPublic = false;
private isMultiple = false;
private chosenIndexes: number[] = [];
private percents: number[];
private pollID: string;
private mid: number;
private quizInterval: number;
private quizTimer: SVGSVGElement;
private sendVoteBtn: HTMLElement;
private chosingIndexes: number[] = [];
private sendVotePromise: Promise<void>;
private sentVote = false;
constructor() {
super();
// элемент создан
}
connectedCallback() {
// браузер вызывает этот метод при добавлении элемента в документ
// (может вызываться много раз, если элемент многократно добавляется/удаляется)
if(!lineTotalLength) {
lineTotalLength = (document.getElementById('poll-line') as any as SVGPathElement).getTotalLength();
//console.log('line total length:', lineTotalLength);
}
this.pollID = this.getAttribute('poll-id');
this.mid = +this.getAttribute('message-id');
const {poll, results} = appPollsManager.getPoll(this.pollID);
connectedPolls.push({id: this.pollID, element: this});
//console.log('pollElement poll:', poll, results);
let desc = '';
if(poll.pFlags) {
this.isPublic = !!poll.pFlags.public_voters;
this.isQuiz = !!poll.pFlags.quiz;
this.isClosed = !!poll.pFlags.closed;
this.isMultiple = !!poll.pFlags.multiple_choice;
if(this.isClosed) {
desc = 'Final results';
this.classList.add('is-closed');
} else {
let type = this.isQuiz ? 'Quiz' : 'Poll';
desc = (this.isPublic ? '' : 'Anonymous ') + type;
}
}
const multipleSelect = this.isMultiple ? '<span class="poll-answer-selected tgico-check"></span>' : '';
const votes = poll.answers.map((answer, idx) => {
return `
<div class="poll-answer" data-index="${idx}">
<div class="circle-hover">
<div class="animation-ring"></div>
<svg class="progress-ring">
<circle class="progress-ring__circle" cx="13" cy="13" r="9"></circle>
</svg>
${multipleSelect}
</div>
<div class="poll-answer-percents"></div>
<div class="poll-answer-text">${RichTextProcessor.wrapEmojiText(answer.text)}</div>
<svg version="1.1" class="poll-line" style="display: none;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${mediaSizes.active.regular.width} 35" xml:space="preserve">
<use href="#poll-line"></use>
</svg>
<span class="poll-answer-selected tgico"></span>
</div>
`;
}).join('');
this.innerHTML = `
<div class="poll-title">${poll.rQuestion}</div>
<div class="poll-desc">
<div class="poll-type">${desc}</div>
<div class="poll-avatars"></div>
</div>
${votes}
<div class="poll-footer">
<div class="poll-footer-button poll-view-results hide">View Results</div>
<div class="poll-votes-count"></div>
</div>
`;
this.descDiv = this.firstElementChild.nextElementSibling as HTMLElement;
this.typeDiv = this.descDiv.firstElementChild as HTMLElement;
this.avatarsDiv = this.descDiv.lastElementChild as HTMLElement;
if(this.isQuiz) {
this.classList.add('is-quiz');
if(poll.close_period && poll.close_date) {
const timeLeftDiv = document.createElement('div');
timeLeftDiv.classList.add('poll-time');
this.descDiv.append(timeLeftDiv);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
//svg.setAttributeNS(null, 'viewBox', '0 0 15 15');
svg.classList.add('poll-quiz-timer');
this.quizTimer = svg;
const strokeWidth = 2;
const radius = 7;
const circumference = 2 * Math.PI * radius;
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.classList.add('poll-quiz-timer-circle');
circle.setAttributeNS(null, 'cx', '16');
circle.setAttributeNS(null, 'cy', '16');
circle.setAttributeNS(null, 'r', '' + radius);
circle.setAttributeNS(null, 'stroke-width', '' + strokeWidth);
svg.append(circle);
this.descDiv.append(svg);
const period = poll.close_period * 1000;
const closeTime = (poll.close_date - serverTimeManager.serverTimeOffset) * 1000;
//console.log('closeTime:', poll.close_date, serverTimeManager.serverTimeOffset, Date.now() / 1000 | 0);
// let time = Date.now();
// let percents = (closeTime - time) / period;
// timeLeftDiv.innerHTML = String((closeTime - time) / 1000 + 1 | 0).toHHMMSS();
// // @ts-ignore
// circle.style.strokeDashoffset = circumference + percents * circumference;
// circle.style.strokeDasharray = ${circumference} ${circumference};
this.quizInterval = window.setInterval(() => {
const time = Date.now();
const percents = (closeTime - time) / period;
const timeLeft = (closeTime - time) / 1000 + 1 | 0;
timeLeftDiv.innerHTML = String(timeLeft).toHHMMSS();
if (timeLeft <= 5) {
timeLeftDiv.style.color = '#ee545c';
circle.style.stroke = '#ee545c';
}
//timeLeftDiv.style.visibility = 'visible';
// @ts-ignore
circle.style.strokeDashoffset = circumference + percents * circumference;
circle.style.strokeDasharray = `${circumference} ${circumference}`;
if(time >= closeTime) {
clearInterval(this.quizInterval);
timeLeftDiv.innerHTML = '';
// @ts-ignore
circle.style.strokeDashoffset = circumference;
this.quizInterval = 0;
setTimeout(() => {
// нужно запросить апдейт чтобы опрос обновился
appPollsManager.getResults(this.mid);
}, 3e3);
}
}, 1e3);
}
}
this.answerDivs = Array.from(this.querySelectorAll('.poll-answer')) as HTMLDivElement[];
this.svgLines = Array.from(this.querySelectorAll('.poll-line')) as SVGSVGElement[];
this.numberDivs = Array.from(this.querySelectorAll('.poll-answer-percents')) as HTMLDivElement[];
const footerDiv = this.lastElementChild;
this.viewResults = footerDiv.firstElementChild as HTMLElement;
this.votersCountDiv = footerDiv.lastElementChild as HTMLDivElement;
this.viewResults.addEventListener('click', (e) => {
cancelEvent(e);
appSidebarRight.pollResultsTab.init(this.pollID, this.mid);
});
ripple(this.viewResults);
if(this.isMultiple) {
this.sendVoteBtn = document.createElement('div');
this.sendVoteBtn.classList.add('poll-footer-button', 'poll-send-vote');
this.sendVoteBtn.innerText = 'Vote';
ripple(this.sendVoteBtn);
if(!poll.chosenIndexes.length) {
this.votersCountDiv.classList.add('hide');
}
this.sendVoteBtn.addEventListener('click', () => {
/* const indexes = this.answerDivs.filter(el => el.classList.contains('is-chosing')).map(el => +el.dataset.index);
if(indexes.length) {
} */
if(this.chosingIndexes.length) {
this.sendVotes(this.chosingIndexes).then(() => {
this.chosingIndexes.length = 0;
this.answerDivs.forEach(el => {
el.classList.remove('is-chosing');
});
});
}
});
footerDiv.append(this.sendVoteBtn);
}
const width = this.getBoundingClientRect().width;
this.maxLength = width + tailLength + this.maxOffset + -13.7; // 13 - position left
if(poll.chosenIndexes.length || this.isClosed) {
this.performResults(results, poll.chosenIndexes);
} else if(!this.isClosed) {
this.setVotersCount(results);
this.addEventListener('click', this.clickHandler);
}
}
disconnectedCallback() {
// браузер вызывает этот метод при удалении элемента из документа
// (может вызываться много раз, если элемент многократно добавляется/удаляется)
connectedPolls.findAndSplice(c => c.element == this);
}
static get observedAttributes(): string[] {
return ['poll-id', 'message-id'/* массив имён атрибутов для отслеживания их изменений */];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
// вызывается при изменении одного из перечисленных выше атрибутов
if(name == 'poll-id') {
this.pollID = newValue;
} else if(name == 'message-id') {
this.mid = +newValue;
}
}
adoptedCallback() {
// вызывается, когда элемент перемещается в новый документ
// (происходит в document.adoptNode, используется очень редко)
}
initQuizHint(results: PollResults) {
if(results.solution && results.solution_entities) {
const toggleHint = document.createElement('div');
toggleHint.classList.add('tgico-tip', 'poll-hint');
this.descDiv.append(toggleHint);
//let active = false;
toggleHint.addEventListener('click', (e) => {
cancelEvent(e);
//active = true;
toggleHint.classList.add('active');
setQuizHint(results.solution, results.solution_entities, () => {
//active = false;
toggleHint.classList.remove('active');
});
});
if(this.sentVote) {
const correctResult = results.results.find(r => r.pFlags.correct);
if(correctResult && !correctResult.pFlags.chosen) {
toggleHint.click();
}
}
}
}
clickHandler(e: MouseEvent) {
const target = findUpClassName(e.target, 'poll-answer') as HTMLElement;
if(!target) {
return;
}
const answerIndex = +target.dataset.index;
if(this.isMultiple) {
target.classList.toggle('is-chosing');
const foundIndex = this.chosingIndexes.indexOf(answerIndex);
if(foundIndex !== -1) {
this.chosingIndexes.splice(foundIndex, 1);
} else {
this.chosingIndexes.push(answerIndex);
}
} else {
this.sendVotes([answerIndex]);
}
/* target.classList.add('is-voting');
setTimeout(() => { // simulate
this.setResults([100, 0], answerIndex);
target.classList.remove('is-voting');
}, 1000); */
}
sendVotes(indexes: number[]) {
if(this.sendVotePromise) return this.sendVotePromise;
const targets = this.answerDivs.filter((_, idx) => indexes.includes(idx));
targets.forEach(target => {
target.classList.add('is-voting');
});
this.classList.add('disable-hover');
this.sentVote = true;
return this.sendVotePromise = appPollsManager.sendVote(this.mid, indexes).then(() => {
targets.forEach(target => {
target.classList.remove('is-voting');
});
this.classList.remove('disable-hover');
}).catch(() => {
this.sentVote = false;
}).finally(() => {
this.sendVotePromise = null;
});
}
performResults(results: PollResults, chosenIndexes: number[]) {
if(this.isQuiz && (results.results?.length || this.isClosed)) {
this.answerDivs.forEach((el, idx) => {
el.classList.toggle('is-correct', !!results.results[idx].pFlags.correct);
});
if(this.initQuizHint) {
this.initQuizHint(results);
this.initQuizHint = null;
}
if(this.quizInterval) {
clearInterval(this.quizInterval);
this.quizInterval = 0;
}
if(this.quizTimer?.parentElement) {
this.quizTimer.remove();
}
const timeEl = this.descDiv.querySelector('.poll-time');
if(timeEl) {
timeEl.remove();
}
}
if(this.isClosed) {
this.classList.add('is-closed');
this.typeDiv.innerText = 'Final results';
}
// set chosen
if(this.chosenIndexes.length != chosenIndexes.length || this.isClosed) { // if we voted
this.isRetracted = this.chosenIndexes.length && !chosenIndexes.length;
this.chosenIndexes = chosenIndexes.slice();
if(this.isRetracted) {
this.addEventListener('click', this.clickHandler);
} else {
this.removeEventListener('click', this.clickHandler);
}
}
// is need update
if(this.chosenIndexes.length || this.isRetracted || this.isClosed) {
const percents = results.results.map(v => results.total_voters ? v.voters / results.total_voters * 100 : 0);
this.setResults(this.isRetracted ? this.percents : percents, this.chosenIndexes);
this.percents = percents;
this.isRetracted = false;
}
this.setVotersCount(results);
if(this.isPublic) {
if(!this.isMultiple) {
this.viewResults.classList.toggle('hide', !results.total_voters || !this.chosenIndexes.length);
this.votersCountDiv.classList.toggle('hide', !!this.chosenIndexes.length);
}
let html = '';
/**
* MACOS, ANDROID - без реверса
* WINDOWS DESKTOP - реверс
* все приложения накладывают аватарку первую на вторую, а в макете зато вторая на первую, ЛОЛ!
*/
results.recent_voters/* .slice().reverse() */.forEach((userID, idx) => {
const style = idx == 0 ? '' : `style="transform: translateX(-${idx * 3}px);"`;
html += `<avatar-element dialog="0" peer="${userID}" ${style}></avatar-element>`;
});
this.avatarsDiv.innerHTML = html;
}
if(this.isMultiple) {
this.sendVoteBtn.classList.toggle('hide', !!this.chosenIndexes.length);
if(!this.chosenIndexes.length) {
this.votersCountDiv.classList.add('hide');
this.viewResults.classList.add('hide');
} else if(this.isPublic) {
this.viewResults.classList.toggle('hide', !results.total_voters || !this.chosenIndexes.length);
this.votersCountDiv.classList.toggle('hide', !!this.chosenIndexes.length);
} else {
this.votersCountDiv.classList.toggle('hide', !this.chosenIndexes.length);
}
}
}
setResults(percents: number[], chosenIndexes: number[]) {
this.svgLines.forEach(svg => svg.style.display = '');
this.answerDivs.forEach((el, idx) => {
el.classList.toggle('is-chosen', chosenIndexes.includes(idx));
});
const maxValue = Math.max(...percents);
this.maxLengths = percents.map(p => p / maxValue * this.maxLength);
// line
if(this.isRetracted) {
this.svgLines.forEach((svg, idx) => {
this.setLineProgress(idx, -1);
});
} else {
this.svgLines.forEach((svg, idx) => {
void svg.getBoundingClientRect(); // reflow
this.setLineProgress(idx, 1);
});
}
percents = percents.slice();
roundPercents(percents);
// numbers
if(this.isRetracted) {
for(let i = (times - 1), k = 0; i >= 0; --i, ++k) {
setTimeout(() => {
percents.forEach((percents, idx) => {
const value = Math.round(percents / times * i);
this.numberDivs[idx].innerText = value + '%';
});
}, oneTime * k);
}
} else {
for(let i = 0; i < times; ++i) {
setTimeout(() => {
percents.forEach((percents, idx) => {
const value = Math.round(percents / times * (i + 1));
this.numberDivs[idx].innerText = value + '%';
});
}, oneTime * i);
}
}
if(this.isRetracted) {
this.classList.add('is-retracting');
this.classList.remove('is-voted');
setTimeout(() => {
this.classList.remove('is-retracting');
this.svgLines.forEach(svg => svg.style.display = 'none');
}, fullTime);
} else {
this.classList.add('is-voted');
}
}
setVotersCount(results: PollResults) {
const votersCount = results.total_voters || 0;
const votersOrAnswers = this.isQuiz ? (votersCount > 1 || !votersCount ? 'answers' : 'answer') : (votersCount > 1 || !votersCount ? 'votes' : 'vote');
this.votersCountDiv.innerText = `${results.total_voters ? results.total_voters + ' ' + votersOrAnswers : 'No ' + votersOrAnswers}`;
}
setLineProgress(index: number, percents: number) {
const svg = this.svgLines[index];
if(percents == -1) {
svg.style.strokeDasharray = '';
svg.style.strokeDashoffset = '';
} else {
svg.style.strokeDasharray = (percents * this.maxLengths[index]) + ', 485.9';
svg.style.strokeDashoffset = '' + percents * this.maxOffset;
}
}
// у элемента могут быть ещё другие методы и свойства
}
customElements.define("poll-element", PollElement);