tweb/src/components/poll.ts

734 lines
25 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.

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import mediaSizes from "../helpers/mediaSizes";
import { IS_TOUCH_SUPPORTED } from "../environment/touchSupport";
import appImManager from "../lib/appManagers/appImManager";
import appPollsManager from "../lib/appManagers/appPollsManager";
import serverTimeManager from "../lib/mtproto/serverTimeManager";
import { RichTextProcessor } from "../lib/richtextprocessor";
import rootScope from "../lib/rootScope";
import { ripple } from "./ripple";
import appSidebarRight from "./sidebarRight";
import AppPollResultsTab from "./sidebarRight/tabs/pollResults";
import { i18n, LangPackKey } from "../lib/langPack";
import { fastRaf } from "../helpers/schedulers";
import SetTransition from "./singleTransition";
import findUpClassName from "../helpers/dom/findUpClassName";
import { cancelEvent } from "../helpers/dom/cancelEvent";
import { attachClickEvent, detachClickEvent } from "../helpers/dom/clickEvent";
import replaceContent from "../helpers/dom/replaceContent";
import windowSize from "../helpers/windowSize";
import { Poll, PollResults } from "../layer";
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 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.addEventListener('poll_update', ({poll, results}) => {
const pollElements = Array.from(document.querySelectorAll(`poll-element[poll-id="${poll.id}"]`)) as PollElement[];
pollElements.forEach(pollElement => {
//console.log('poll_update', poll, results);
pollElement.isClosed = !!poll.pFlags.closed;
pollElement.performResults(results, poll.chosenIndexes);
});
});
rootScope.addEventListener('peer_changed', () => {
if(prevQuizHint) {
hideQuizHint(prevQuizHint, prevQuizHintOnHide, prevQuizHintTimeout);
}
});
mediaSizes.addEventListener('resize', () => {
PollElement.setMaxLength();
PollElement.resizePolls();
});
mediaSizes.addEventListener('changeScreen', () => {
PollElement.setMaxLength();
});
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.chat.bubbles.bubblesContainer.append(element);
void element.offsetLeft; // reflow
element.classList.add('active');
prevQuizHint = element;
prevQuizHintOnHide = onHide;
prevQuizHintTimeout = window.setTimeout(() => {
hideQuizHint(element, onHide, prevQuizHintTimeout);
}, IS_TOUCH_SUPPORTED ? 5000 : 7000);
};
export default class PollElement extends HTMLElement {
public static MAX_OFFSET = -46.5;
public static MAX_LENGTH = 0;
public svgLines: SVGSVGElement[];
private numberDivs: HTMLDivElement[];
private answerDivs: HTMLDivElement[];
private descDiv: HTMLElement;
private typeDiv: HTMLElement;
private avatarsDiv: HTMLElement;
private viewResults: HTMLElement;
private votersCountDiv: HTMLDivElement;
// private maxLength: number;
// private maxLengths: number[];
private maxPercents: number[];
public isClosed = false;
private isQuiz = false;
private isRetracted = false;
private isPublic = false;
private isMultiple = false;
private chosenIndexes: number[] = [];
private percents: number[];
public message: any;
private quizInterval: number;
private quizTimer: SVGSVGElement;
private sendVoteBtn: HTMLElement;
private chosingIndexes: number[] = [];
private sendVotePromise: Promise<void>;
private sentVote = false;
constructor() {
super();
// элемент создан
}
public static setMaxLength() {
const width = windowSize.windowW <= 360 ? windowSize.windowW - 120 : mediaSizes.active.poll.width;
this.MAX_LENGTH = width + tailLength + this.MAX_OFFSET + -13.7; // 13 - position left
}
public static resizePolls() {
if(!this.MAX_LENGTH) return;
const pollElements = Array.from(document.querySelectorAll('poll-element.is-voted')) as PollElement[];
pollElements.forEach(pollElement => {
pollElement.svgLines.forEach((svg, idx) => {
//void svg.getBoundingClientRect(); // reflow
pollElement.setLineProgress(idx, 1);
});
});
}
public render() {
// браузер вызывает этот метод при добавлении элемента в документ
// (может вызываться много раз, если элемент многократно добавляется/удаляется)
if(!lineTotalLength) {
lineTotalLength = (document.getElementById('poll-line') as any as SVGPathElement).getTotalLength();
//console.log('line total length:', lineTotalLength);
PollElement.setMaxLength();
}
const pollId = this.message.media.poll.id;
const {poll, results} = appPollsManager.getPoll(pollId);
/* const timestamp = Date.now() / 1000 | 0;
if(timestamp < this.message.date) { */
if(this.message.pFlags.is_scheduled) {
this.classList.add('disable-hover');
}
//console.log('pollElement poll:', poll, results);
let descKey: LangPackKey;
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) {
descKey = 'Chat.Poll.Type.Closed';
this.classList.add('is-closed');
} else if(this.isQuiz) {
descKey = this.isPublic ? 'Chat.Poll.Type.Quiz' : 'Chat.Poll.Type.AnonymousQuiz';
} else {
descKey = this.isPublic ? 'Chat.Poll.Type.Public' : 'Chat.Poll.Type.Anonymous';
}
}
this.classList.toggle('is-multiple', this.isMultiple);
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 485.9 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"></div>
<div class="poll-avatars"></div>
</div>
${votes}`;
this.descDiv = this.firstElementChild.nextElementSibling as HTMLElement;
this.typeDiv = this.descDiv.firstElementChild as HTMLElement;
this.avatarsDiv = this.descDiv.lastElementChild as HTMLElement;
if(descKey) {
this.typeDiv.append(i18n(descKey));
}
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.message);
}, 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 = document.createElement('div');
footerDiv.classList.add('poll-footer');
this.viewResults = document.createElement('div');
this.viewResults.className = 'poll-footer-button poll-view-results hide';
this.viewResults.append(i18n('Chat.Poll.ViewResults'));
this.votersCountDiv = document.createElement('div');
this.votersCountDiv.className = 'poll-votes-count';
footerDiv.append(this.viewResults, this.votersCountDiv);
this.append(footerDiv);
this.viewResults.addEventListener('click', (e) => {
cancelEvent(e);
if(!appSidebarRight.isTabExists(AppPollResultsTab)) {
new AppPollResultsTab(appSidebarRight).open(this.message);
}
});
ripple(this.viewResults);
if(this.isMultiple) {
this.sendVoteBtn = document.createElement('div');
this.sendVoteBtn.classList.add('poll-footer-button', 'poll-send-vote');
this.sendVoteBtn.append(i18n('Chat.Poll.SubmitVote'));
ripple(this.sendVoteBtn);
if(!poll.chosenIndexes.length) {
this.votersCountDiv.classList.add('hide');
}
attachClickEvent(this.sendVoteBtn, (e) => {
cancelEvent(e);
/* 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;
// const width = mediaSizes.active.poll.width;
// this.maxLength = width + tailLength + this.maxOffset + -13.7; // 13 - position left
const canVote = !(poll.chosenIndexes.length || this.isClosed);
if(!canVote || this.isPublic) {
this.performResults(results, poll.chosenIndexes, false);
}
if(canVote) {
this.setVotersCount(results);
attachClickEvent(this, this.clickHandler);
}
}
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;
attachClickEvent(toggleHint, (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: Event) {
const target = findUpClassName(e.target, 'poll-answer') as HTMLElement;
if(!target) {
return;
}
cancelEvent(e);
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.message, 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[], animate = true) {
if(!rootScope.settings.animationsEnabled) {
animate = false;
}
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');
replaceContent(this.typeDiv, i18n('Chat.Poll.Type.Closed'));
}
// 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) {
attachClickEvent(this, this.clickHandler);
} else {
detachClickEvent(this, 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.classList.toggle('no-transition', !animate);
if(animate) {
SetTransition(this, '', !this.isRetracted, 340);
}
fastRaf(() => {
this.setResults(this.isRetracted ? this.percents : percents, this.chosenIndexes, animate);
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 class="avatar-16 poll-avatar" dialog="0" peer="${userId}" ${style}></avatar-element>`;
});
this.avatarsDiv.innerHTML = html;
}
if(this.isMultiple) {
const isVoted = !!this.chosenIndexes.length;
const hideSendVoteBtn = this.isClosed || isVoted;
const hideViewResultsBtn = !this.isPublic || !results.total_voters || (!isVoted && !this.isClosed);
this.sendVoteBtn.classList.toggle('hide', hideSendVoteBtn);
this.viewResults.classList.toggle('hide', hideViewResultsBtn);
this.votersCountDiv.classList.toggle('hide', !hideSendVoteBtn || !hideViewResultsBtn);
}
}
setResults(percents: number[], chosenIndexes: number[], animate: boolean) {
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);
this.maxPercents = percents.map(p => p / maxValue);
// line
if(this.isRetracted) {
this.svgLines.forEach((svg, idx) => {
this.setLineProgress(idx, -1);
});
} else {
const cb = () => {
this.svgLines.forEach((svg, idx) => {
//void svg.getBoundingClientRect(); // reflow
this.setLineProgress(idx, 1);
});
};
animate ? fastRaf(cb) : cb();
}
percents = percents.slice();
roundPercents(percents);
let getPercentValue: (percents: number, index: number) => number;
const iterate = (i: number) => {
percents.forEach((percents, idx) => {
const value = getPercentValue(percents, i);
this.numberDivs[idx].innerText = value + '%';
});
};
// numbers
if(this.isRetracted) {
getPercentValue = (percents, index) => Math.round(percents / times * index);
if(animate) {
for(let i = (times - 1), k = 0; i >= 0; --i, ++k) {
setTimeout(() => {
iterate(i);
}, oneTime * k);
}
} else {
iterate(0);
}
} else {
getPercentValue = (percents, index) => Math.round(percents / times * (index + 1));
if(animate) {
for(let i = 0; i < times; ++i) {
setTimeout(() => {
iterate(i);
}, oneTime * i);
}
} else {
iterate(times - 1);
}
}
if(this.isRetracted) {
if(animate) {
this.classList.add('is-retracting');
}
this.classList.remove('is-voted');
const cb = () => {
this.svgLines.forEach(svg => svg.style.display = 'none');
};
if(animate) {
setTimeout(() => {
this.classList.remove('is-retracting');
cb();
}, fullTime);
} else {
cb();
}
} else {
this.classList.add('is-voted');
}
}
setVotersCount(results: PollResults) {
const votersCount = results.total_voters || 0;
let key: LangPackKey, args = [votersCount];
if(this.isClosed) {
if(this.isQuiz) key = votersCount ? 'Chat.Quiz.TotalVotes' : 'Chat.Quiz.TotalVotesResultEmpty';
else key = votersCount ? 'Chat.Poll.TotalVotes1' : 'Chat.Poll.TotalVotesResultEmpty';
} else {
if(this.isQuiz) key = votersCount ? 'Chat.Quiz.TotalVotes' : 'Chat.Quiz.TotalVotesEmpty';
else key = votersCount ? 'Chat.Poll.TotalVotes1' : 'Chat.Poll.TotalVotesEmpty';
}
replaceContent(this.votersCountDiv, i18n(key, args));
}
setLineProgress(index: number, multiplier: number) {
const svg = this.svgLines[index];
if(multiplier === -1) {
svg.style.strokeDasharray = '';
svg.style.strokeDashoffset = '';
} else {
// svg.style.strokeDasharray = (multiplier * this.maxLengths[index]) + ', 485.9';
svg.style.strokeDasharray = (multiplier * this.maxPercents[index] * PollElement.MAX_LENGTH) + ', 485.9';
// svg.style.strokeDasharray = (multiplier * this.maxPercents[index] * 100) + '%, 485.9';
svg.style.strokeDashoffset = '' + multiplier * PollElement.MAX_OFFSET;
}
}
// у элемента могут быть ещё другие методы и свойства
}
customElements.define("poll-element", PollElement);