From 268c0e770e73f367fda388fb0a4a265a85689eb8 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Wed, 6 May 2020 19:15:49 +0300 Subject: [PATCH 01/26] wip: Initial support for polls --- packages/mtproto-js | 2 +- src/client/workers/mocks/files.ts | 2 +- src/components/media/poll/poll.ts | 12 ++++++++++++ src/components/message/message.ts | 20 ++++++++++++++++++-- src/const/api.ts | 2 +- 5 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 src/components/media/poll/poll.ts diff --git a/packages/mtproto-js b/packages/mtproto-js index dd0637f3..472045b1 160000 --- a/packages/mtproto-js +++ b/packages/mtproto-js @@ -1 +1 @@ -Subproject commit dd0637f3f18de089f230de3715458ffe86ad7bd4 +Subproject commit 472045b1e0d3c31ec9cf30d1c63f42ab5e0bbf57 diff --git a/src/client/workers/mocks/files.ts b/src/client/workers/mocks/files.ts index 1847b5b4..8f63fc4b 100644 --- a/src/client/workers/mocks/files.ts +++ b/src/client/workers/mocks/files.ts @@ -7,7 +7,7 @@ import walterProfilePhoto from './users/walter.jpg'; import audio from './documents/audio.mp3'; import videoStreamingPreview from './documents/video_streaming_preview.jpg'; import videoStreaming from './documents/video_streaming.mp4'; -import { InputFileLocation } from '../../../../packages/mtproto-js/src/tl/layer105/types'; +import { InputFileLocation } from '../../../../packages/mtproto-js/src/tl/layer113/types'; export const fileMap: Record = { '/photos/4b3881d91b31ad38_x': photoSquare, diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts new file mode 100644 index 00000000..492f15a4 --- /dev/null +++ b/src/components/media/poll/poll.ts @@ -0,0 +1,12 @@ +import { Poll, PollResults } from 'mtproto-js'; +import { div, text } from 'core/html'; +import { mount } from 'core/dom'; + +export default function poll(pollData: Poll.poll, results: PollResults.pollResults) { + const element = div`.poll`( + div`.poll-question`(text(pollData.question)), + ); + pollData.answers.forEach((a) => mount(element, text(a.text))); + + return element; +} diff --git a/src/components/message/message.ts b/src/components/message/message.ts index 43a53b8c..d8218eae 100644 --- a/src/components/message/message.ts +++ b/src/components/message/message.ts @@ -13,6 +13,7 @@ import documentFile from 'components/media/document/file'; import videoPreview from 'components/media/video/preview'; import videoRenderer from 'components/media/video/video'; import audio from 'components/media/audio/audio'; +import poll from 'components/media/poll/poll'; import { messageToSenderPeer, peerToColorCode } from 'cache/accessors'; import { userIdToPeer, peerToId } from 'helpers/api'; import { isEmoji } from 'helpers/message'; @@ -158,7 +159,8 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: if (msg.media._ === 'messageMediaDocument' && msg.media.document?._ === 'document' && getAttributeVideo(msg.media.document)) { const extraClass = hasMessage ? 'with-photo' : 'only-photo'; const previewEl = videoPreview(msg.media.document, { - fit: 'contain', width: 320, height: 320, minHeight: 60, minWidth: msg.message ? 320 : undefined }, peer, msg); + fit: 'contain', width: 320, height: 320, minHeight: 60, minWidth: msg.message ? 320 : undefined + }, peer, msg); if (!hasMessage && previewEl instanceof Element) previewEl.classList.add('raw'); return { @@ -202,7 +204,21 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: }; } - // console.log(msg.media); + // with poll + if (msg.media._ === 'messageMediaPoll') { + const extraClass = hasMessage ? 'with-poll' : 'only-poll'; + return { + message: bubble( + { out, className: extraClass }, + reply, + div`.message__media-padded`(poll(msg.media.poll, msg.media.results)), + messageText(msg, info), + ), + info, + }; + } + + console.log(msg); // fallback return { diff --git a/src/const/api.ts b/src/const/api.ts index 3392f5b9..24b13a38 100644 --- a/src/const/api.ts +++ b/src/const/api.ts @@ -11,7 +11,7 @@ export const CLIENT_CONFIG = { debug: false, protocol: 'intermediate', transport: 'websocket', - APILayer: 105, + APILayer: 113, APIID: API_ID, APIHash: API_HASH, deviceModel: 'test', From a5e9b5d0b79c4051e02f335a6a573be96ffb33a3 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Thu, 7 May 2020 01:35:32 +0300 Subject: [PATCH 02/26] wip: Polls --- src/components/media/poll/poll.scss | 48 +++++++ src/components/media/poll/poll.ts | 125 ++++++++++++++++-- src/components/message/message.ts | 5 +- .../routes/home/dialog/dialog_message.ts | 3 +- src/services/index.ts | 2 + src/services/polls.ts | 36 +++++ src/services/user.ts | 3 +- 7 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 src/components/media/poll/poll.scss create mode 100644 src/services/polls.ts diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss new file mode 100644 index 00000000..f0153624 --- /dev/null +++ b/src/components/media/poll/poll.scss @@ -0,0 +1,48 @@ +.poll { + &__question { + font-weight: 500; + } + + &__type { + color: var(--accent-color-inactive); + font-size: 0.9rem; + } + + &__voters { + color: var(--accent-color-inactive); + font-size: 0.9rem; + } + + &__option { + display: grid; + grid-template-columns: 50px auto; + width: 300px; + } + + &__option-text { + grid-column: 2; + margin: 10px 0 10px 0; + } + + &__option-percentage { + grid-row: 1; + grid-column: 1; + margin: 10px 0 10px 0; + font-weight: 500; + text-align: center; + } + + &__option-line { + grid-row: 1; + grid-column: 1 / 2; + margin-top: 15px; + path { + stroke: var(--accent-color); + stroke-width: 4px; + stroke-linecap: round; + fill: none; + stroke-dasharray: 0 25 263 1000; + stroke-dashoffset: 1; + } + } +} diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 492f15a4..6325a6cf 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -1,12 +1,121 @@ -import { Poll, PollResults } from 'mtproto-js'; -import { div, text } from 'core/html'; -import { mount } from 'core/dom'; +import { Poll, PollResults, PollAnswerVoters, PollAnswer } from 'mtproto-js'; +import { div, text, span } from 'core/html'; +import { mount, svgEl } from 'core/dom'; +import { useWhileMounted, useInterface, getInterface } from 'core/hooks'; +import { polls } from 'services'; -export default function poll(pollData: Poll.poll, results: PollResults.pollResults) { - const element = div`.poll`( - div`.poll-question`(text(pollData.question)), +import './poll.scss'; + +const decoder = new TextDecoder(); +const ease = (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; + +function pollOption(option: PollAnswer, initialVoters?: PollAnswerVoters, initialTotalVoters?: number) { + let path: SVGPathElement; + const percentage = text(''); + const container = div`.poll__option`( + span`.poll__option-text`(text(option.text)), + svgEl('svg', { width: 300, height: 30, class: 'poll__option-line' }, [ + path = svgEl('path', { d: 'M20 8 a 15 15 0 0 0 15 15 H 298' }), + ]), + span`.poll__option-percentage`(percentage), ); - pollData.answers.forEach((a) => mount(element, text(a.text))); - return element; + let voters = initialVoters; + let totalVoters = initialTotalVoters ?? 1; + // let currentPercentage = 0; + // let targetPercentage = 0; + let startTime: number; + const update = (t: number) => { + if (totalVoters > 0) { + const p = (voters?.voters ?? 0) / totalVoters; + percentage.textContent = `${Math.floor(t * p * 100)}%`; + path.style.strokeDasharray = `0 ${Math.round(t * 40)} ${Math.round(t * p * 248)} 1000`; + } else { + percentage.textContent = ''; + path.style.strokeDasharray = '0 0 0 1000'; + } + }; + const raf = (time: number) => { + if (!startTime) { + startTime = time; + } + const t = ease((time - startTime) / (1000 * 0.4)); + update(t); + if (t < 1) { + requestAnimationFrame(raf); + } else { + startTime = 0; + } + }; + + update(1); + + const updateOption = (updateVoters: PollAnswerVoters, updateTotalVoters: number) => { + voters = updateVoters; + totalVoters = updateTotalVoters; + // targetPercentage = totalVoters > 0 ? (voters?.voters ?? 0) / totalVoters : 0; + requestAnimationFrame(raf); + }; + + return useInterface(container, { + updateOption, + }); +} + +type PollOptionInterface = ReturnType; + +export default function poll(pollData: Poll, results: PollResults, info: HTMLElement) { + const pollOptions = div`.poll__options`(); + const totalVotersText = text(''); + const container = span`.poll`( + div`.poll__question`(text(pollData.question)), + div`poll__type`(pollData.public_voters ? text('Poll') : text('Anonymous Poll')), + pollOptions, + span`.poll__voters`(totalVotersText), + info, + ); + const options = new Map(); + pollData.answers.forEach((a) => { + const optionKey = decoder.decode(a.option); + let voters: PollAnswerVoters | undefined; + if (results.results) { + voters = results.results.find((r) => decoder.decode(r.option) === optionKey); + } + const option = pollOption(a, voters, results.total_voters); + options.set(optionKey, option); + mount(pollOptions, option); + }); + + const updateTotalVotersText = (totalVoters: number) => { + totalVotersText.textContent = totalVoters > 0 + ? `${totalVoters} voter${totalVoters > 1 ? 's' : ''}` + : 'No voters yet'; + }; + + const updatePollResults = (pollResults: PollResults) => { + const totalVoters = pollResults.total_voters ?? 0; + updateTotalVotersText(totalVoters); + if (pollResults.results) { + pollResults.results!.forEach((r) => { + const op = options.get(decoder.decode(r.option)); + if (op) { + getInterface(op).updateOption(r, totalVoters); + } + }); + } + }; + + updateTotalVotersText(results.total_voters ?? 0); + + const updateListener = (update: PollResults) => { + updatePollResults(update); + console.log(update); + }; + + useWhileMounted(container, () => { + polls.addListener(pollData.id, updateListener); + return () => polls.removeListener(pollData.id, updateListener); + }); + + return container; } diff --git a/src/components/message/message.ts b/src/components/message/message.ts index d8218eae..d4abf605 100644 --- a/src/components/message/message.ts +++ b/src/components/message/message.ts @@ -211,8 +211,9 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: message: bubble( { out, className: extraClass }, reply, - div`.message__media-padded`(poll(msg.media.poll, msg.media.results)), - messageText(msg, info), + div`.message__text`( + poll(msg.media.poll, msg.media.results, info), + ), ), info, }; diff --git a/src/components/routes/home/dialog/dialog_message.ts b/src/components/routes/home/dialog/dialog_message.ts index 8d4af200..cc1586d3 100644 --- a/src/components/routes/home/dialog/dialog_message.ts +++ b/src/components/routes/home/dialog/dialog_message.ts @@ -4,11 +4,10 @@ import { peerMessageToId } from 'helpers/api'; import { Dialog } from 'mtproto-js'; import { typingIndicator } from 'components/ui'; import messageShort from 'components/message/short'; -import { todoAssertHasValue } from 'helpers/other'; export default function dialogMessage(dialog: Dialog) { const msg = messageCache.get(peerMessageToId(dialog.peer, dialog.top_message)); - const user = msg && msg._ !== 'messageEmpty' ? userCache.get(todoAssertHasValue(msg.from_id)) : undefined; + const user = msg && msg._ !== 'messageEmpty' && msg.from_id ? userCache.get(msg.from_id) : undefined; const userLabel = user?._ === 'user' ? user.first_name : ''; const content = msg ? messageShort(msg) : ''; diff --git a/src/services/index.ts b/src/services/index.ts index dc6e324a..04189747 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -9,6 +9,7 @@ import MessageSearchService from './message_search/message_search'; import UserTyping from './user_typing'; import UserService from './user'; import TopUsersService from './top_users'; +import PollsService from './polls'; export { AuthStage } from './auth'; export { RightSidebarPanel } from './main'; @@ -24,3 +25,4 @@ export const media = new MediaService(main); export const messageSearch = new MessageSearchService(); export const topUsers = new TopUsersService(message); export const globalSearch = new GlobalSearchService(topUsers, dialog); +export const polls = new PollsService(); diff --git a/src/services/polls.ts b/src/services/polls.ts new file mode 100644 index 00000000..272d856f --- /dev/null +++ b/src/services/polls.ts @@ -0,0 +1,36 @@ +import client from 'client/client'; +import { Update, PollResults } from 'mtproto-js'; + +type PollListener = (update: PollResults.pollResults) => void; + +export default class PollsService { + private subscriptions = new Map}>(); + + constructor() { + client.updates.on('updateMessagePoll', (update: Update.updateMessagePoll) => { + const subscription = this.subscriptions.get(update.poll_id); + if (subscription) { + subscription.lastUpdate = update.results; + subscription.listeners.forEach((listener) => listener(update.results)); + } + }); + } + + public addListener(pollId: string, listener: PollListener) { + let supscription = this.subscriptions.get(pollId); + if (!supscription) { + this.subscriptions.set(pollId, supscription = { listeners: new Set() }); + } + supscription.listeners.add(listener); + if (supscription.lastUpdate) { + listener(supscription.lastUpdate); + } + } + + public removeListener(pollId: string, listener: PollListener) { + const subscription = this.subscriptions.get(pollId); + if (subscription) { + subscription.listeners.delete(listener); + } + } +} diff --git a/src/services/user.ts b/src/services/user.ts index 37ef1606..4e359806 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -1,9 +1,10 @@ import client from 'client/client'; +import { Update } from 'mtproto-js'; import { userCache } from '../cache'; export default class UsersService { constructor() { - client.updates.on('updateUserStatus', (update) => { + client.updates.on('updateUserStatus', (update: Update.updateUserStatus) => { const user = userCache.get(update.user_id); if (user && user._ !== 'userEmpty') { userCache.put({ ...user, status: update.status }); From d05a081156c9d4d384021bf1e91986a674451055 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Thu, 7 May 2020 09:35:56 +0300 Subject: [PATCH 03/26] mtproto updated --- packages/mtproto-js | 2 +- src/components/media/poll/poll.scss | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mtproto-js b/packages/mtproto-js index 472045b1..600de8b7 160000 --- a/packages/mtproto-js +++ b/packages/mtproto-js @@ -1 +1 @@ -Subproject commit 472045b1e0d3c31ec9cf30d1c63f42ab5e0bbf57 +Subproject commit 600de8b7122dfc94eb61d9e1cc3aa4c666185437 diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index f0153624..e62f27da 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -27,9 +27,9 @@ &__option-percentage { grid-row: 1; grid-column: 1; - margin: 10px 0 10px 0; + margin: 10px 10px 10px 0; font-weight: 500; - text-align: center; + text-align: right; } &__option-line { From bed9ad2c2db6dbd5ee68e1e1be23285b2c1ae595 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Thu, 7 May 2020 09:39:56 +0300 Subject: [PATCH 04/26] mtproto updated --- packages/mtproto-js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mtproto-js b/packages/mtproto-js index 600de8b7..4af61891 160000 --- a/packages/mtproto-js +++ b/packages/mtproto-js @@ -1 +1 @@ -Subproject commit 600de8b7122dfc94eb61d9e1cc3aa4c666185437 +Subproject commit 4af61891385ebfd82293a0710efb10a44f00dd4c From 245c8855f96407f0a53838bbd1187595ae2d792a Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Thu, 7 May 2020 10:58:23 +0300 Subject: [PATCH 05/26] Minor refactoring --- src/components/media/poll/poll.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 6325a6cf..35e75160 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -64,12 +64,22 @@ function pollOption(option: PollAnswer, initialVoters?: PollAnswerVoters, initia type PollOptionInterface = ReturnType; +function pollType(pollData: Poll) { + if (pollData.quiz) { + return 'Quiz'; + } + if (pollData.public_voters) { + return 'Poll'; + } + return 'Anonymous Poll'; +} + export default function poll(pollData: Poll, results: PollResults, info: HTMLElement) { const pollOptions = div`.poll__options`(); const totalVotersText = text(''); const container = span`.poll`( div`.poll__question`(text(pollData.question)), - div`poll__type`(pollData.public_voters ? text('Poll') : text('Anonymous Poll')), + div`poll__type`(text(pollType(pollData))), pollOptions, span`.poll__voters`(totalVotersText), info, @@ -109,7 +119,6 @@ export default function poll(pollData: Poll, results: PollResults, info: HTMLEle const updateListener = (update: PollResults) => { updatePollResults(update); - console.log(update); }; useWhileMounted(container, () => { From 8c86b37a37302c3aa2ac910a1083d278ab9558a9 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Fri, 8 May 2020 10:36:13 +0300 Subject: [PATCH 06/26] Votes in polls --- src/components/media/poll/poll-checkbox.scss | 49 +++++++++ src/components/media/poll/poll-checkbox.ts | 35 +++++++ src/components/media/poll/poll-option.scss | 56 ++++++++++ src/components/media/poll/poll-option.ts | 100 ++++++++++++++++++ src/components/media/poll/poll.scss | 33 ------ src/components/media/poll/poll.ts | 105 ++++++------------- src/components/media/poll/spinner.svg | 11 ++ src/components/message/message.ts | 2 +- src/helpers/other.ts | 2 +- src/services/polls.ts | 42 +++++--- storybook/decorators.ts | 2 - 11 files changed, 316 insertions(+), 121 deletions(-) create mode 100644 src/components/media/poll/poll-checkbox.scss create mode 100644 src/components/media/poll/poll-checkbox.ts create mode 100644 src/components/media/poll/poll-option.scss create mode 100644 src/components/media/poll/poll-option.ts create mode 100644 src/components/media/poll/spinner.svg diff --git a/src/components/media/poll/poll-checkbox.scss b/src/components/media/poll/poll-checkbox.scss new file mode 100644 index 00000000..e97f8e26 --- /dev/null +++ b/src/components/media/poll/poll-checkbox.scss @@ -0,0 +1,49 @@ +.pollCheckbox { + $ripple-color: #707579; + $ripple-size: 44px; + $transition-duration: 0.3s; + position: relative; + display: grid; + align-items: center; + justify-items: center; + grid-template-columns: auto; + >*{ + grid-row: 1; + grid-column: 1; + } + &__backdrop { + width: 26px; + height: 26px; + border-radius: 100%; + background-color: rgba(0, 0, 0, .08); + } + &__spinner { + margin: auto; + fill: none; + stroke: #707579; + stroke-width: 2px; + stroke-dasharray: 43 100; + } + & .checkbox__box { + border-radius: 100%; + border-color: #8d969c; + } + + &__ripple { + border-radius: 100%; + width: $ripple-size; + height: $ripple-size; + position: absolute; + top: calc(50% - #{$ripple-size / 2}); + left: calc(50% - #{$ripple-size / 2}); + pointer-events: none; + animation: pollCheckbox-ripple ($transition-duration * 1.4) ease-out forwards; + background: $ripple-color; + } +} + +@keyframes pollCheckbox-ripple { + 0% { transform: scale(0.2); opacity: 0; } + 60% { transform: scale(1); opacity: 0.2; } + 100% { transform: scale(1); opacity: 0; } +} diff --git a/src/components/media/poll/poll-checkbox.ts b/src/components/media/poll/poll-checkbox.ts new file mode 100644 index 00000000..2cdd5541 --- /dev/null +++ b/src/components/media/poll/poll-checkbox.ts @@ -0,0 +1,35 @@ +import { checkbox } from 'components/ui'; +import { div } from 'core/html'; +import { mount, unmount } from 'core/dom'; +import { svgCodeToComponent } from 'core/factory'; +import { getInterface } from 'core/hooks'; +import spinnerCode from './spinner.svg?raw'; + +import './poll-checkbox.scss'; + +const spinnerSvg = svgCodeToComponent(spinnerCode); + +export default function pollCheckbox(multiple: boolean, clickCallback: (reset: () => void) => void) { + if (multiple) { + return div`.pollCheckbox`(checkbox()); + } + const container = div`.pollCheckbox`(); + const cb = checkbox({ + onChange: () => { + unmount(cb); + const effect = div`.pollCheckbox__ripple`({ + onAnimationEnd: () => setTimeout(() => unmount(effect), 1000), + }); + mount(container, effect); + const spinner = spinnerSvg(); + mount(container, spinner); + clickCallback(() => { + unmount(spinner); + getInterface(cb).setChecked(false); + mount(container, cb); + }); + }, + }); + mount(container, cb); + return container; +} diff --git a/src/components/media/poll/poll-option.scss b/src/components/media/poll/poll-option.scss new file mode 100644 index 00000000..5243ad0f --- /dev/null +++ b/src/components/media/poll/poll-option.scss @@ -0,0 +1,56 @@ +.pollOption { + display: grid; + grid-template-columns: 40px auto; + width: 300px; + + &__text { + grid-column: 2; + margin: auto 0 auto 8px; + } + + &__percentage { + grid-row: 1; + grid-column: 1; + margin: auto 0; + font-weight: bold; + text-align: center; + transition: opacity 0.3s; + opacity: 0; + } + + &__checkbox { + grid-row: 1; + grid-column: 1; + margin: auto; + transition: opacity 0.3s; + } + + &__line { + grid-row: 1; + grid-column: 1 / 2; + margin-top: 15px; + path { + stroke: var(--accent-color); + stroke-width: 4px; + stroke-linecap: round; + fill: none; + stroke-dasharray: 0 25 263 1000; + stroke-dashoffset: 1; + } + } + + &__checkbox.-answered { + opacity: 0; + pointer-events: none; + } + + &__percentage.-answered { + opacity: 1; + } +} + +.pollOption.-incorrect { + path { + stroke: red; + } +} diff --git a/src/components/media/poll/poll-option.ts b/src/components/media/poll/poll-option.ts new file mode 100644 index 00000000..820ff6fb --- /dev/null +++ b/src/components/media/poll/poll-option.ts @@ -0,0 +1,100 @@ +import { useInterface } from 'core/hooks'; +import { PollAnswer, PollAnswerVoters } from 'mtproto-js'; +import { text, span, div } from 'core/html'; +import { svgEl } from 'core/dom'; +import pollCheckbox from './poll-checkbox'; + +import './poll-option.scss'; + +// const ease = (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; +const ease = (t: number) => t; + +type Props = { + quiz: boolean, + multiple: boolean, + option: PollAnswer, + answered: boolean, + initialVoters: PollAnswerVoters | undefined, + initialTotalVoters: number, + clickCallback: (reset: () => void) => void, +}; + +export default function pollOption({ quiz, multiple, option, answered, initialVoters, initialTotalVoters, clickCallback }: Props) { + let path: SVGPathElement; + const checkbox = span`.pollOption__checkbox`(pollCheckbox(multiple, clickCallback)); + const percentage = span`.pollOption__percentage`(); + const container = div`.pollOption`( + svgEl('svg', { width: 300, height: 30, class: 'pollOption__line' }, [ + path = svgEl('path', { d: 'M20 8 v 7 a 13 13 0 0 0 13 13 H 300' }), + ]), + checkbox, + percentage, + span`.pollOption__text`(text(option.text)), + ); + + let voters = initialVoters; + let totalVoters = initialTotalVoters ?? 1; + // let currentPercentage = 0; + // let targetPercentage = 0; + let startTime: number; + const update = (t: number) => { + if (quiz && voters) { + if (voters.chosen) { + if (voters.correct) { + container.classList.add('-correct'); + } else { + container.classList.add('-incorrect'); + } + } else if (voters.correct) { + container.classList.add('-correct'); + } + } + + if (totalVoters > 0) { + const p = (voters?.voters ?? 0) / totalVoters; + percentage.textContent = `${Math.floor(t * p * 100)}%`; + path.style.strokeDasharray = `0 ${Math.round(t * 45)} ${Math.round(t * p * 248)} 1000`; + } else { + percentage.textContent = ''; + path.style.strokeDasharray = '0 0 0 1000'; + } + }; + const raf = (time: number) => { + if (!startTime) { + startTime = time; + } + const t = ease((time - startTime) / (1000 * 0.4)); + update(Math.min(t, 1)); + if (t < 1) { + requestAnimationFrame(raf); + } else { + startTime = 0; + } + }; + + const updateOption = ( + updateVoters: PollAnswerVoters | undefined, + animate: boolean, + updateAnswered: boolean, + updateMaxVoters: number, + updateTotalVoters: number) => { + checkbox.classList.toggle('-answered', updateAnswered); + percentage.classList.toggle('-answered', updateAnswered); + voters = updateVoters; + totalVoters = updateTotalVoters; + // targetPercentage = totalVoters > 0 ? (voters?.voters ?? 0) / totalVoters : 0; + if (animate) { + requestAnimationFrame(raf); + } else { + update(1); + } + }; + + updateOption(initialVoters, false, answered, 0, totalVoters); + + return useInterface(container, { + updateOption, + }); +} + +export type PollOptionInterface = ReturnType; diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index e62f27da..628d5ce2 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -12,37 +12,4 @@ color: var(--accent-color-inactive); font-size: 0.9rem; } - - &__option { - display: grid; - grid-template-columns: 50px auto; - width: 300px; - } - - &__option-text { - grid-column: 2; - margin: 10px 0 10px 0; - } - - &__option-percentage { - grid-row: 1; - grid-column: 1; - margin: 10px 10px 10px 0; - font-weight: 500; - text-align: right; - } - - &__option-line { - grid-row: 1; - grid-column: 1 / 2; - margin-top: 15px; - path { - stroke: var(--accent-color); - stroke-width: 4px; - stroke-linecap: round; - fill: none; - stroke-dasharray: 0 25 263 1000; - stroke-dashoffset: 1; - } - } } diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 35e75160..dc86b738 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -1,70 +1,17 @@ -import { Poll, PollResults, PollAnswerVoters, PollAnswer } from 'mtproto-js'; +import { Poll, PollResults, PollAnswerVoters, Peer } from 'mtproto-js'; import { div, text, span } from 'core/html'; -import { mount, svgEl } from 'core/dom'; -import { useWhileMounted, useInterface, getInterface } from 'core/hooks'; +import { mount } from 'core/dom'; +import { useWhileMounted, getInterface } from 'core/hooks'; import { polls } from 'services'; import './poll.scss'; +import pollOption, { PollOptionInterface } from './poll-option'; const decoder = new TextDecoder(); -const ease = (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; - -function pollOption(option: PollAnswer, initialVoters?: PollAnswerVoters, initialTotalVoters?: number) { - let path: SVGPathElement; - const percentage = text(''); - const container = div`.poll__option`( - span`.poll__option-text`(text(option.text)), - svgEl('svg', { width: 300, height: 30, class: 'poll__option-line' }, [ - path = svgEl('path', { d: 'M20 8 a 15 15 0 0 0 15 15 H 298' }), - ]), - span`.poll__option-percentage`(percentage), - ); - - let voters = initialVoters; - let totalVoters = initialTotalVoters ?? 1; - // let currentPercentage = 0; - // let targetPercentage = 0; - let startTime: number; - const update = (t: number) => { - if (totalVoters > 0) { - const p = (voters?.voters ?? 0) / totalVoters; - percentage.textContent = `${Math.floor(t * p * 100)}%`; - path.style.strokeDasharray = `0 ${Math.round(t * 40)} ${Math.round(t * p * 248)} 1000`; - } else { - percentage.textContent = ''; - path.style.strokeDasharray = '0 0 0 1000'; - } - }; - const raf = (time: number) => { - if (!startTime) { - startTime = time; - } - const t = ease((time - startTime) / (1000 * 0.4)); - update(t); - if (t < 1) { - requestAnimationFrame(raf); - } else { - startTime = 0; - } - }; - - update(1); - - const updateOption = (updateVoters: PollAnswerVoters, updateTotalVoters: number) => { - voters = updateVoters; - totalVoters = updateTotalVoters; - // targetPercentage = totalVoters > 0 ? (voters?.voters ?? 0) / totalVoters : 0; - requestAnimationFrame(raf); - }; - - return useInterface(container, { - updateOption, - }); -} - -type PollOptionInterface = ReturnType; - function pollType(pollData: Poll) { + if (pollData.closed) { + return 'Final Results'; + } if (pollData.quiz) { return 'Quiz'; } @@ -74,7 +21,7 @@ function pollType(pollData: Poll) { return 'Anonymous Poll'; } -export default function poll(pollData: Poll, results: PollResults, info: HTMLElement) { +export default function poll(peer: Peer, messageId: number, pollData: Poll, results: PollResults, info: HTMLElement) { const pollOptions = div`.poll__options`(); const totalVotersText = text(''); const container = span`.poll`( @@ -85,13 +32,25 @@ export default function poll(pollData: Poll, results: PollResults, info: HTMLEle info, ); const options = new Map(); - pollData.answers.forEach((a) => { - const optionKey = decoder.decode(a.option); + const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; + pollData.answers.forEach((answer) => { + const optionKey = decoder.decode(answer.option); let voters: PollAnswerVoters | undefined; if (results.results) { voters = results.results.find((r) => decoder.decode(r.option) === optionKey); } - const option = pollOption(a, voters, results.total_voters); + const option = pollOption({ + quiz: pollData.quiz ?? false, + multiple: pollData.multiple_choice ?? false, + option: answer, + answered, + initialVoters: voters, + initialTotalVoters: results.total_voters ?? 0, + clickCallback: async (reset: () => void) => { + await polls.sendVote(peer, messageId, [answer.option]); + reset(); + }, + }); options.set(optionKey, option); mount(pollOptions, option); }); @@ -102,28 +61,30 @@ export default function poll(pollData: Poll, results: PollResults, info: HTMLEle : 'No voters yet'; }; - const updatePollResults = (pollResults: PollResults) => { + const updatePollResults = (pollResults: PollResults, initial: boolean) => { const totalVoters = pollResults.total_voters ?? 0; updateTotalVotersText(totalVoters); if (pollResults.results) { - pollResults.results!.forEach((r) => { + const maxVoters = Math.max(...pollResults.results.map((r) => r.voters)); + const updateAnswered = pollResults.results.findIndex((r) => r.chosen) >= 0; + pollResults.results.forEach((r) => { const op = options.get(decoder.decode(r.option)); if (op) { - getInterface(op).updateOption(r, totalVoters); + getInterface(op).updateOption(r, !initial, updateAnswered, maxVoters, totalVoters); } }); } }; - updateTotalVotersText(results.total_voters ?? 0); + updatePollResults(results, true); - const updateListener = (update: PollResults) => { - updatePollResults(update); + const updateListener = (update: PollResults, initial: boolean) => { + updatePollResults(update, initial); }; useWhileMounted(container, () => { - polls.addListener(pollData.id, updateListener); - return () => polls.removeListener(pollData.id, updateListener); + polls.subscribe(pollData.id, updateListener); + return () => polls.unsubscribe(pollData.id, updateListener); }); return container; diff --git a/src/components/media/poll/spinner.svg b/src/components/media/poll/spinner.svg new file mode 100644 index 00000000..5d7b023a --- /dev/null +++ b/src/components/media/poll/spinner.svg @@ -0,0 +1,11 @@ + + + + + diff --git a/src/components/message/message.ts b/src/components/message/message.ts index d4abf605..8108827c 100644 --- a/src/components/message/message.ts +++ b/src/components/message/message.ts @@ -212,7 +212,7 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: { out, className: extraClass }, reply, div`.message__text`( - poll(msg.media.poll, msg.media.results, info), + poll(peer, msg.id, msg.media.poll, msg.media.results, info), ), ), info, diff --git a/src/helpers/other.ts b/src/helpers/other.ts index 466b0968..5e0ea18d 100644 --- a/src/helpers/other.ts +++ b/src/helpers/other.ts @@ -1,4 +1,4 @@ -import { a, form, input } from 'core/html'; +import { a } from 'core/html'; export type PhotoFitMode = 'contain' | 'cover'; diff --git a/src/services/polls.ts b/src/services/polls.ts index 272d856f..060625aa 100644 --- a/src/services/polls.ts +++ b/src/services/polls.ts @@ -1,36 +1,54 @@ import client from 'client/client'; -import { Update, PollResults } from 'mtproto-js'; +import { Update, PollResults, Peer } from 'mtproto-js'; +import { peerToInputPeer } from 'cache/accessors'; -type PollListener = (update: PollResults.pollResults) => void; +type PollListener = (update: PollResults.pollResults, initial: boolean) => void; export default class PollsService { private subscriptions = new Map}>(); constructor() { - client.updates.on('updateMessagePoll', (update: Update.updateMessagePoll) => { - const subscription = this.subscriptions.get(update.poll_id); - if (subscription) { - subscription.lastUpdate = update.results; - subscription.listeners.forEach((listener) => listener(update.results)); - } - }); + client.updates.on('updateMessagePoll', this.processUpdate); } - public addListener(pollId: string, listener: PollListener) { + public subscribe(pollId: string, listener: PollListener) { let supscription = this.subscriptions.get(pollId); if (!supscription) { this.subscriptions.set(pollId, supscription = { listeners: new Set() }); } supscription.listeners.add(listener); if (supscription.lastUpdate) { - listener(supscription.lastUpdate); + listener(supscription.lastUpdate, true); } } - public removeListener(pollId: string, listener: PollListener) { + public unsubscribe(pollId: string, listener: PollListener) { const subscription = this.subscriptions.get(pollId); if (subscription) { subscription.listeners.delete(listener); } } + + public async sendVote(peer: Peer, messageId: number, options: ArrayBuffer[]) { + const updates = await client.call('messages.sendVote', { + peer: peerToInputPeer(peer), + msg_id: messageId, + options, + }); + if (updates._ === 'updates') { + updates.updates.forEach((update: Update) => { + if (update._ === 'updateMessagePoll') { + this.processUpdate(update); + } + }); + } + } + + private processUpdate = (update: Update.updateMessagePoll) => { + const subscription = this.subscriptions.get(update.poll_id); + if (subscription) { + subscription.lastUpdate = update.results; + subscription.listeners.forEach((listener) => listener(update.results, false)); + } + }; } diff --git a/storybook/decorators.ts b/storybook/decorators.ts index 9fcded6b..d28ee03e 100644 --- a/storybook/decorators.ts +++ b/storybook/decorators.ts @@ -4,10 +4,8 @@ import { StoryContext, StoryFn } from '@storybook/addons'; import { triggerMountRecursive } from 'core/dom'; import { div } from 'core/html'; import chamomile from 'assets/chamomile-blurred.jpg'; -import { emptyCache } from 'client/media'; import popup from 'components/popup/popup'; import 'components/routes/home/home.scss'; -import { task } from 'client/context'; export function withMountTrigger(getStory: StoryFn, context: StoryContext) { const element = getStory(context); From e9b12300b467f188337d704ea6e516e70ac6911e Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Fri, 8 May 2020 11:27:17 +0300 Subject: [PATCH 07/26] Quiz colors --- src/components/media/poll/poll-option.scss | 43 ++++++++++++++++++---- src/components/media/poll/poll-option.ts | 29 ++++++++++++--- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/components/media/poll/poll-option.scss b/src/components/media/poll/poll-option.scss index 5243ad0f..5f16c56e 100644 --- a/src/components/media/poll/poll-option.scss +++ b/src/components/media/poll/poll-option.scss @@ -1,8 +1,20 @@ +.pollOption.-correct { + --color: #00da00; +} + +.pollOption.-incorrect { + --color: red; +} + .pollOption { display: grid; grid-template-columns: 40px auto; width: 300px; + path { + stroke: var(--color); + } + &__text { grid-column: 2; margin: auto 0 auto 8px; @@ -25,12 +37,35 @@ transition: opacity 0.3s; } + & &__answer { + background-color: var(--color, var(--accent-color)); + border-radius: 100%; + grid-row: 1; + grid-column: 1; + width: 12px; + height: 12px; + align-self: end; + justify-self: end; + display: grid; + align-items: center; + justify-items: center; + svg { + width: 10px; + height: 10px; + path { + fill: white; + stroke: white; + stroke-width: 1; + } + } + } + &__line { grid-row: 1; grid-column: 1 / 2; margin-top: 15px; path { - stroke: var(--accent-color); + stroke: var(--color, var(--accent-color)); stroke-width: 4px; stroke-linecap: round; fill: none; @@ -48,9 +83,3 @@ opacity: 1; } } - -.pollOption.-incorrect { - path { - stroke: red; - } -} diff --git a/src/components/media/poll/poll-option.ts b/src/components/media/poll/poll-option.ts index 820ff6fb..8ff381e9 100644 --- a/src/components/media/poll/poll-option.ts +++ b/src/components/media/poll/poll-option.ts @@ -1,7 +1,8 @@ -import { useInterface } from 'core/hooks'; +import { useInterface, getInterface } from 'core/hooks'; import { PollAnswer, PollAnswerVoters } from 'mtproto-js'; import { text, span, div } from 'core/html'; -import { svgEl } from 'core/dom'; +import { svgEl, unmountChildren, mount } from 'core/dom'; +import { close as closeIcon, check as checkIcon } from 'components/icons'; import pollCheckbox from './poll-checkbox'; import './poll-option.scss'; @@ -19,16 +20,31 @@ type Props = { clickCallback: (reset: () => void) => void, }; +function answerIcon() { + const container = div`.pollOption__answer`({ style: { display: 'none' } }); + return useInterface(container, { + update: (isCorrect?: boolean) => { + unmountChildren(container); + if (isCorrect !== undefined) { + container.style.removeProperty('display'); + mount(container, isCorrect ? checkIcon() : closeIcon()); + } + }, + }); +} + export default function pollOption({ quiz, multiple, option, answered, initialVoters, initialTotalVoters, clickCallback }: Props) { let path: SVGPathElement; const checkbox = span`.pollOption__checkbox`(pollCheckbox(multiple, clickCallback)); const percentage = span`.pollOption__percentage`(); + const answer = answerIcon(); const container = div`.pollOption`( svgEl('svg', { width: 300, height: 30, class: 'pollOption__line' }, [ - path = svgEl('path', { d: 'M20 8 v 7 a 13 13 0 0 0 13 13 H 300' }), + path = svgEl('path', { d: 'M20 8 v 3.5 a 13 13 0 0 0 13 13 H 300' }), ]), checkbox, percentage, + answer, span`.pollOption__text`(text(option.text)), ); @@ -42,18 +58,21 @@ export default function pollOption({ quiz, multiple, option, answered, initialVo if (voters.chosen) { if (voters.correct) { container.classList.add('-correct'); + getInterface(answer).update(true); } else { container.classList.add('-incorrect'); + getInterface(answer).update(false); } } else if (voters.correct) { - container.classList.add('-correct'); + // container.classList.add('-correct'); + getInterface(answer).update(true); } } if (totalVoters > 0) { const p = (voters?.voters ?? 0) / totalVoters; percentage.textContent = `${Math.floor(t * p * 100)}%`; - path.style.strokeDasharray = `0 ${Math.round(t * 45)} ${Math.round(t * p * 248)} 1000`; + path.style.strokeDasharray = `0 ${Math.round(t * 41)} ${Math.round(t * p * 248)} 1000`; } else { percentage.textContent = ''; path.style.strokeDasharray = '0 0 0 1000'; From f60755db2447a4497a81d4acba4a2d92e0d2580f Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Fri, 8 May 2020 18:07:32 +0300 Subject: [PATCH 08/26] minor fixes --- src/components/media/poll/poll-option.scss | 7 +++++++ src/components/media/poll/poll-option.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/media/poll/poll-option.scss b/src/components/media/poll/poll-option.scss index 5f16c56e..6aaca663 100644 --- a/src/components/media/poll/poll-option.scss +++ b/src/components/media/poll/poll-option.scss @@ -28,6 +28,8 @@ text-align: center; transition: opacity 0.3s; opacity: 0; + justify-self: end; + cursor: default; } &__checkbox { @@ -83,3 +85,8 @@ opacity: 1; } } + +.checkbox__input:checked + .checkbox__box { + border-color: var(--accent-color); + background: var(--accent-color); +} \ No newline at end of file diff --git a/src/components/media/poll/poll-option.ts b/src/components/media/poll/poll-option.ts index 8ff381e9..fa5da0bc 100644 --- a/src/components/media/poll/poll-option.ts +++ b/src/components/media/poll/poll-option.ts @@ -42,8 +42,8 @@ export default function pollOption({ quiz, multiple, option, answered, initialVo svgEl('svg', { width: 300, height: 30, class: 'pollOption__line' }, [ path = svgEl('path', { d: 'M20 8 v 3.5 a 13 13 0 0 0 13 13 H 300' }), ]), - checkbox, percentage, + checkbox, answer, span`.pollOption__text`(text(option.text)), ); From ed5992df9c72f44fbc42fcaf9c8437d4ad881fe6 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Fri, 8 May 2020 18:46:42 +0300 Subject: [PATCH 09/26] Update mtproto --- packages/mtproto-js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mtproto-js b/packages/mtproto-js index dd0637f3..4f95aca3 160000 --- a/packages/mtproto-js +++ b/packages/mtproto-js @@ -1 +1 @@ -Subproject commit dd0637f3f18de089f230de3715458ffe86ad7bd4 +Subproject commit 4f95aca3aadcd7f896677c57bfb7509fffcc27a8 From 063e317dd3d30d039ddee4e7fe5cc8beec55081a Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Fri, 8 May 2020 21:48:36 +0300 Subject: [PATCH 10/26] Storybook for polls --- src/client/workers/mocks/call.ts | 51 +++++++++++++++++++- src/client/workers/mocks/message.ts | 2 + src/client/workers/mocks/storybook.ts | 31 +++++++++++- src/components/media/poll/poll-checkbox.scss | 2 + src/components/media/poll/poll-option.scss | 8 +-- src/components/message/message.story.ts | 1 + src/components/message/stories/poll.story.ts | 37 ++++++++++++++ 7 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/components/message/stories/poll.story.ts diff --git a/src/client/workers/mocks/call.ts b/src/client/workers/mocks/call.ts index 262596fa..b5cd8ea3 100644 --- a/src/client/workers/mocks/call.ts +++ b/src/client/workers/mocks/call.ts @@ -1,5 +1,5 @@ /* eslint-disable no-param-reassign */ -import { MethodDeclMap } from 'mtproto-js'; +import { MethodDeclMap, PollResults, PollAnswerVoters, MessageEntity } from 'mtproto-js'; import { users } from './user'; import { chats } from './chat'; import { mockDialogForPeers } from './dialog'; @@ -66,6 +66,55 @@ export function callMock(method: T, params: Metho }); break; + case 'messages.sendVote': { + const { options } = params as MethodDeclMap['messages.sendVote']['req']; + const selectedOption = new Uint8Array(options[0])[0]; + const pollAnswerVoters1 = Math.round(Math.random() * 100); + const pollAnswerVoters2 = Math.round(Math.random() * 100); + const pollAnswerVoters3 = Math.round(Math.random() * 100); + const result = { + _: 'updates', + updates: [ + { + _: 'updateMessagePoll', + poll_id: '1', + results: { + _: 'pollResults', + results: [ + { + _: 'pollAnswerVoters', + chosen: selectedOption === 0, + correct: false, + option: new Int8Array([0]).buffer, + voters: pollAnswerVoters1, + }, + { + _: 'pollAnswerVoters', + chosen: selectedOption === 1, + correct: true, + option: new Int8Array([1]).buffer, + voters: pollAnswerVoters2, + }, + { + _: 'pollAnswerVoters', + chosen: selectedOption === 2, + correct: false, + option: new Int8Array([2]).buffer, + voters: pollAnswerVoters3, + }, + ] as PollAnswerVoters[], + total_voters: pollAnswerVoters1 + pollAnswerVoters2 + pollAnswerVoters3, + recent_voters: [], + solution: 'string', + solution_entities: [] as MessageEntity[], + } as PollResults, + }, + ], + }; + timeout(1000, cb, result); + break; + } + default: cb({ type: 'network', code: 100 }, undefined); } diff --git a/src/client/workers/mocks/message.ts b/src/client/workers/mocks/message.ts index 3abac6f6..920cce03 100644 --- a/src/client/workers/mocks/message.ts +++ b/src/client/workers/mocks/message.ts @@ -37,6 +37,7 @@ export function mockMessage({ grouped_id = '', restriction_reason = [], date = 0, + media = undefined, }: Partial & { from_id: number, to_id: Peer }): Message.message { return { _: 'message', @@ -61,5 +62,6 @@ export function mockMessage({ post_author, grouped_id, restriction_reason, + media, }; } diff --git a/src/client/workers/mocks/storybook.ts b/src/client/workers/mocks/storybook.ts index 807fc000..83dab468 100644 --- a/src/client/workers/mocks/storybook.ts +++ b/src/client/workers/mocks/storybook.ts @@ -1,5 +1,5 @@ import { BehaviorSubject } from 'rxjs'; -import type { Photo } from 'mtproto-js'; +import { Photo } from 'mtproto-js'; import { task } from 'client/context'; import { getPhotoLocation } from 'helpers/photo'; import { locationToURL } from 'helpers/files'; @@ -51,3 +51,32 @@ export const MessageRegular = mockMessage({ from_id: users[1].id, to_id: { _: 'peerUser', user_id: me.id }, }); + +export const MessagePoll = mockMessage({ + from_id: users[1].id, + to_id: { _: 'peerUser', user_id: me.id }, + media: { + _: 'messageMediaPoll', + poll: { + _: 'poll' as const, + id: '1', + question: 'Question', + answers: [ + { + _: 'pollAnswer', + text: 'Option 1', + option: new ArrayBuffer(1), + }, + { + _: 'pollAnswer', + text: 'Option 2', + option: new ArrayBuffer(1), + }, + ], + + }, + results: { + _: 'pollResults', + }, + }, +}); diff --git a/src/components/media/poll/poll-checkbox.scss b/src/components/media/poll/poll-checkbox.scss index e97f8e26..2e71b4b0 100644 --- a/src/components/media/poll/poll-checkbox.scss +++ b/src/components/media/poll/poll-checkbox.scss @@ -7,6 +7,8 @@ align-items: center; justify-items: center; grid-template-columns: auto; + width: 24px; + height: 24px; >*{ grid-row: 1; grid-column: 1; diff --git a/src/components/media/poll/poll-option.scss b/src/components/media/poll/poll-option.scss index 6aaca663..07a979ea 100644 --- a/src/components/media/poll/poll-option.scss +++ b/src/components/media/poll/poll-option.scss @@ -10,6 +10,7 @@ display: grid; grid-template-columns: 40px auto; width: 300px; + margin-top: 10px; path { stroke: var(--color); @@ -17,13 +18,13 @@ &__text { grid-column: 2; - margin: auto 0 auto 8px; + margin: auto 0 12px 8px; } &__percentage { grid-row: 1; grid-column: 1; - margin: auto 0; + margin: auto 0 12px 0; font-weight: bold; text-align: center; transition: opacity 0.3s; @@ -36,6 +37,7 @@ grid-row: 1; grid-column: 1; margin: auto; + margin-bottom: 10px; transition: opacity 0.3s; } @@ -65,7 +67,7 @@ &__line { grid-row: 1; grid-column: 1 / 2; - margin-top: 15px; + align-self: end; path { stroke: var(--color, var(--accent-color)); stroke-width: 4px; diff --git a/src/components/message/message.story.ts b/src/components/message/message.story.ts index d0dd9ee5..26d10fe2 100644 --- a/src/components/message/message.story.ts +++ b/src/components/message/message.story.ts @@ -11,6 +11,7 @@ import message from './message'; // Stories with media require('./stories/photo.story'); +require('./stories/poll.story'); // Stories with text const stories = storiesOf('Layout | Message', module) diff --git a/src/components/message/stories/poll.story.ts b/src/components/message/stories/poll.story.ts new file mode 100644 index 00000000..99783564 --- /dev/null +++ b/src/components/message/stories/poll.story.ts @@ -0,0 +1,37 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { storiesOf } from '@storybook/html'; +import { withKnobs, boolean, text } from '@storybook/addon-knobs'; +import { withMountTrigger, withChatLayout } from 'storybook/decorators'; +import { messageCache, userCache } from 'cache'; +import { peerMessageToId } from 'helpers/api'; +import { MessagePoll } from 'mocks/storybook'; +import { users } from 'mocks/user'; +import message from '../message'; +import { MessageMedia } from '../../../../packages/mtproto-js/src/tl/layer113/types'; + +const stories = storiesOf('Layout | Message', module) + .addDecorator(withMountTrigger) + .addDecorator(withChatLayout) + .addDecorator(withKnobs); + +stories.add('Poll', () => { + MessagePoll.out = boolean('Out', false); + const poll = MessagePoll.media as MessageMedia.messageMediaPoll; + poll.poll.question = text('Question', 'Tea lovers! What is your favorite tea?'); + poll.poll.public_voters = boolean('Public voters', false); + poll.poll.multiple_choice = boolean('Multiple choice', false); + poll.poll.quiz = boolean('Quiz', false); + poll.poll.closed = boolean('Closed', false); + const answers = text('Answers', 'Green Tea, Black Tea, Herbal Tea'); + poll.poll.answers = answers.split(',').map((answer, i) => ({ + _: 'pollAnswer', + text: answer.trim(), + option: new Uint8Array([i]).buffer, + })); + + messageCache.put(MessagePoll); + userCache.put(users[0]); + userCache.put(users[1]); + + return message(peerMessageToId(MessagePoll.to_id, MessagePoll.id), MessagePoll.to_id); +}); From 77d9c3fc51d721630a827736165a93107a650563 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sun, 10 May 2020 11:31:26 +0300 Subject: [PATCH 11/26] Update message cache on poll updates --- src/components/media/poll/poll.ts | 5 +-- src/services/polls.ts | 69 ++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index dc86b738..c0a72a10 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -82,10 +82,7 @@ export default function poll(peer: Peer, messageId: number, pollData: Poll, resu updatePollResults(update, initial); }; - useWhileMounted(container, () => { - polls.subscribe(pollData.id, updateListener); - return () => polls.unsubscribe(pollData.id, updateListener); - }); + useWhileMounted(container, () => polls.subscribe(peer, messageId, updateListener)); return container; } diff --git a/src/services/polls.ts b/src/services/polls.ts index 060625aa..27b784b2 100644 --- a/src/services/polls.ts +++ b/src/services/polls.ts @@ -1,31 +1,49 @@ import client from 'client/client'; import { Update, PollResults, Peer } from 'mtproto-js'; import { peerToInputPeer } from 'cache/accessors'; +import { messageCache } from 'cache'; +import { peerMessageToId } from 'helpers/api'; type PollListener = (update: PollResults.pollResults, initial: boolean) => void; export default class PollsService { - private subscriptions = new Map}>(); + private subscriptions = new Map>(); + private pollMessages = new Map(); constructor() { client.updates.on('updateMessagePoll', this.processUpdate); } - public subscribe(pollId: string, listener: PollListener) { - let supscription = this.subscriptions.get(pollId); - if (!supscription) { - this.subscriptions.set(pollId, supscription = { listeners: new Set() }); + private getMediaPoll(peer: Peer, messageId: number) { + const message = messageCache.get(peerMessageToId(peer, messageId)); + if (message?._ === 'message' && message.media?._ === 'messageMediaPoll') { + return message.media; } - supscription.listeners.add(listener); - if (supscription.lastUpdate) { - listener(supscription.lastUpdate, true); + return undefined; + } + + public subscribe(peer: Peer, messageId: number, listener: PollListener) { + const mediaPoll = this.getMediaPoll(peer, messageId); + if (!mediaPoll) return () => { }; + let pollMessages = this.pollMessages.get(mediaPoll.poll.id); + if (!pollMessages) { + this.pollMessages.set(mediaPoll.poll.id, pollMessages = []); + } + pollMessages.push({ peer, messageId }); + let supscriptionSet = this.subscriptions.get(mediaPoll.poll.id); + if (!supscriptionSet) { + this.subscriptions.set(mediaPoll.poll.id, supscriptionSet = new Set()); } + supscriptionSet.add(listener); + return () => this.unsubscribe(peer, messageId, listener); } - public unsubscribe(pollId: string, listener: PollListener) { - const subscription = this.subscriptions.get(pollId); - if (subscription) { - subscription.listeners.delete(listener); + private unsubscribe(peer: Peer, messageId: number, listener: PollListener) { + const mediaPoll = this.getMediaPoll(peer, messageId); + if (!mediaPoll) return; + const subscriptionSet = this.subscriptions.get(mediaPoll.poll.id); + if (subscriptionSet) { + subscriptionSet.delete(listener); } } @@ -45,10 +63,29 @@ export default class PollsService { } private processUpdate = (update: Update.updateMessagePoll) => { - const subscription = this.subscriptions.get(update.poll_id); - if (subscription) { - subscription.lastUpdate = update.results; - subscription.listeners.forEach((listener) => listener(update.results, false)); + const messages = this.pollMessages.get(update.poll_id); + if (messages) { + messages.forEach((messageId) => { + const id = peerMessageToId(messageId.peer, messageId.messageId); + const message = messageCache.get(id); + if (message?._ === 'message' && message.media?._ === 'messageMediaPoll') { + const updatedMedia = { + ...message.media, + results: update.results, + }; + if (update.poll) { + updatedMedia.poll = update.poll; + } + const updatedMessage = { + media: updatedMedia, + }; + messageCache.change(id, updatedMessage); + } + }); + } + const subscriptionSet = this.subscriptions.get(update.poll_id); + if (subscriptionSet) { + subscriptionSet.forEach((listener) => listener(update.results, false)); } }; } From f2f6b2bfaac4d445a3b9e972823139cbf260f76f Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sun, 10 May 2020 17:32:47 +0300 Subject: [PATCH 12/26] Poll message cache refactored --- src/cache/fastStorages/indices/pollsIndex.ts | 80 +++++++++++++ src/cache/index.ts | 2 + src/components/media/poll/poll-option.scss | 14 ++- src/components/media/poll/poll-option.ts | 120 ++++++++++--------- src/components/media/poll/poll.scss | 1 + src/components/media/poll/poll.ts | 65 ++++++---- src/components/message/message.ts | 4 +- src/services/polls.ts | 66 +--------- 8 files changed, 202 insertions(+), 150 deletions(-) create mode 100644 src/cache/fastStorages/indices/pollsIndex.ts diff --git a/src/cache/fastStorages/indices/pollsIndex.ts b/src/cache/fastStorages/indices/pollsIndex.ts new file mode 100644 index 00000000..96df1354 --- /dev/null +++ b/src/cache/fastStorages/indices/pollsIndex.ts @@ -0,0 +1,80 @@ +import { Message, Update, PollResults } from 'mtproto-js'; +import { messageToId } from 'helpers/api'; +import Collection from '../collection'; + +export default function pollsIndex(collection: Collection) { + const pollMessageIds = new Map>(); + collection.changes.subscribe((collectionChanges) => { + collectionChanges.forEach(([action, item]) => { + if (item._ !== 'message' || item.media?._ !== 'messageMediaPoll') { + return; + } + switch (action) { + case 'add': { + let messages = pollMessageIds.get(item.media.poll.id); + if (!messages) { + pollMessageIds.set(item.media.poll.id, messages = new Set()); + } + messages.add(messageToId(item)); + break; + } + case 'remove': { + const messages = pollMessageIds.get(item.media.poll.id); + if (messages) { + messages.delete(messageToId(item)); + } + break; + } + default: + } + }, + ); + }); + + const expandMinUpdateResults = (prevResults: PollResults, newResults: PollResults) => { + if (!newResults.min) { + return newResults; + } + const result: PollResults = { ...prevResults, ...newResults }; + if (prevResults.results && newResults.results) { + const results = result.results = [...newResults.results.map((r) => ({ ...r }))]; + const decoder = new TextDecoder(); + const prevResultOptions = new Map(prevResults.results.map((r) => [decoder.decode(r.option), r])); + results.forEach((option) => { + const prevOptions = prevResultOptions.get(decoder.decode(option.option)); + if (prevOptions) { + // eslint-disable-next-line no-param-reassign + option.correct = prevOptions.correct; + // eslint-disable-next-line no-param-reassign + option.chosen = prevOptions.chosen; + } + }); + } + return result; + }; + + return { + updatePoll(update: Update.updateMessagePoll) { + const messageIds = pollMessageIds.get(update.poll_id); + if (messageIds) { + messageIds.forEach((messageId) => { + const message = collection.get(messageId); + if (message?._ === 'message' && message.media?._ === 'messageMediaPoll') { + const expandedResults = expandMinUpdateResults(message.media.results, update.results); + const updatedMedia = { + ...message.media, + results: expandedResults, + }; + if (update.poll) { + updatedMedia.poll = update.poll; + } + const updatedMessage = { + media: updatedMedia, + }; + collection.change(messageId, updatedMessage); + } + }); + } + }, + }; +} diff --git a/src/cache/index.ts b/src/cache/index.ts index 93b843f2..3abd3e77 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -11,6 +11,7 @@ import Collection, { GetId, makeGetIdFromProp } from './fastStorages/collection' import { orderBy } from './fastStorages/indices'; import messageHistory from './fastStorages/indices/messageHistory'; import sharedMediaIndex from './fastStorages/indices/sharedMediaIndex'; +import pollsIndex from './fastStorages/indices/pollsIndex'; // todo: Save the main part of the cache to a persistent storage @@ -53,6 +54,7 @@ export const messageCache = new Collection({ photoVideos: sharedMediaIndex, documents: sharedMediaIndex, links: sharedMediaIndex, + polls: pollsIndex, }, }); diff --git a/src/components/media/poll/poll-option.scss b/src/components/media/poll/poll-option.scss index 07a979ea..225bbd0d 100644 --- a/src/components/media/poll/poll-option.scss +++ b/src/components/media/poll/poll-option.scss @@ -16,12 +16,12 @@ stroke: var(--color); } - &__text { + & &__text { grid-column: 2; margin: auto 0 12px 8px; } - &__percentage { + & &__percentage { grid-row: 1; grid-column: 1; margin: auto 0 12px 0; @@ -33,7 +33,7 @@ cursor: default; } - &__checkbox { + & &__checkbox { grid-row: 1; grid-column: 1; margin: auto; @@ -64,10 +64,12 @@ } } - &__line { + & &__line { grid-row: 1; grid-column: 1 / 2; align-self: end; + opacity: 0; + transition: opacity 0.1s; path { stroke: var(--color, var(--accent-color)); stroke-width: 4px; @@ -86,6 +88,10 @@ &__percentage.-answered { opacity: 1; } + + &__line.-answered { + opacity: 1; + } } .checkbox__input:checked + .checkbox__box { diff --git a/src/components/media/poll/poll-option.ts b/src/components/media/poll/poll-option.ts index fa5da0bc..f01a1a22 100644 --- a/src/components/media/poll/poll-option.ts +++ b/src/components/media/poll/poll-option.ts @@ -7,16 +7,15 @@ import pollCheckbox from './poll-checkbox'; import './poll-option.scss'; -// const ease = (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; -const ease = (t: number) => t; - type Props = { quiz: boolean, - multiple: boolean, + multipleChoice: boolean, option: PollAnswer, answered: boolean, - initialVoters: PollAnswerVoters | undefined, - initialTotalVoters: number, + closed: boolean, + voters?: PollAnswerVoters, + maxVoters: number, + totalVoters: number, clickCallback: (reset: () => void) => void, }; @@ -33,83 +32,90 @@ function answerIcon() { }); } -export default function pollOption({ quiz, multiple, option, answered, initialVoters, initialTotalVoters, clickCallback }: Props) { +export default function pollOption(initialProps: Props) { + let prevProps = initialProps; + let currProps = initialProps; + let firstAnimationCompleted = initialProps.answered; + let path: SVGPathElement; - const checkbox = span`.pollOption__checkbox`(pollCheckbox(multiple, clickCallback)); + const checkbox = span`.pollOption__checkbox`(pollCheckbox(currProps.multipleChoice, currProps.clickCallback)); const percentage = span`.pollOption__percentage`(); const answer = answerIcon(); + const line = svgEl('svg', { width: 300, height: 30, class: 'pollOption__line' }, [ + path = svgEl('path', { d: 'M20 8 v 3.5 a 13 13 0 0 0 13 13 H 300' }), + ]); const container = div`.pollOption`( - svgEl('svg', { width: 300, height: 30, class: 'pollOption__line' }, [ - path = svgEl('path', { d: 'M20 8 v 3.5 a 13 13 0 0 0 13 13 H 300' }), - ]), + line, percentage, checkbox, answer, - span`.pollOption__text`(text(option.text)), + span`.pollOption__text`(text(currProps.option.text)), ); - let voters = initialVoters; - let totalVoters = initialTotalVoters ?? 1; - // let currentPercentage = 0; - // let targetPercentage = 0; - let startTime: number; const update = (t: number) => { - if (quiz && voters) { - if (voters.chosen) { - if (voters.correct) { - container.classList.add('-correct'); - getInterface(answer).update(true); - } else { - container.classList.add('-incorrect'); - getInterface(answer).update(false); - } - } else if (voters.correct) { - // container.classList.add('-correct'); - getInterface(answer).update(true); - } - } - - if (totalVoters > 0) { - const p = (voters?.voters ?? 0) / totalVoters; - percentage.textContent = `${Math.floor(t * p * 100)}%`; - path.style.strokeDasharray = `0 ${Math.round(t * 41)} ${Math.round(t * p * 248)} 1000`; - } else { - percentage.textContent = ''; - path.style.strokeDasharray = '0 0 0 1000'; - } + const prevVoters = firstAnimationCompleted ? prevProps.voters?.voters ?? 0 : 0; + const currVoters = currProps.voters?.voters ?? 0; + const p1 = prevVoters > 0 ? prevVoters / prevProps.totalVoters : 0; + const p2 = currVoters > 0 ? currVoters / currProps.totalVoters : 0; + const p = p1 + (p2 - p1) * t; + percentage.textContent = `${Math.round(p * 100)}%`; + const x1 = prevVoters > 0 ? prevVoters / prevProps.maxVoters : 0; + const x2 = currVoters > 0 ? currVoters / currProps.maxVoters : 0; + const x = x1 + (x2 - x1) * t; + const t1 = firstAnimationCompleted ? 1 : t; + path.style.strokeDasharray = `0 ${Math.round(t1 * 41)} ${Math.round(x * 248)} 1000`; }; - const raf = (time: number) => { + + let startTime: number; + const rafCallback = (time: number) => { if (!startTime) { startTime = time; } - const t = ease((time - startTime) / (1000 * 0.4)); + const t = (time - startTime) / (1000 * 0.3); update(Math.min(t, 1)); if (t < 1) { - requestAnimationFrame(raf); + requestAnimationFrame(rafCallback); } else { startTime = 0; + prevProps = currProps; + if (currProps.answered) { + firstAnimationCompleted = true; + } } }; - const updateOption = ( - updateVoters: PollAnswerVoters | undefined, - animate: boolean, - updateAnswered: boolean, - updateMaxVoters: number, - updateTotalVoters: number) => { - checkbox.classList.toggle('-answered', updateAnswered); - percentage.classList.toggle('-answered', updateAnswered); - voters = updateVoters; - totalVoters = updateTotalVoters; - // targetPercentage = totalVoters > 0 ? (voters?.voters ?? 0) / totalVoters : 0; - if (animate) { - requestAnimationFrame(raf); + const updateOption = (updatedProps: Partial) => { + if (updatedProps !== currProps) { + currProps = { ...currProps, ...updatedProps }; + } + if (!currProps.answered) { + firstAnimationCompleted = false; + } + const answered = currProps.answered || currProps.closed; + checkbox.classList.toggle('-answered', answered); + percentage.classList.toggle('-answered', answered); + line.classList.toggle('-answered', answered); + if (currProps.quiz && currProps.voters) { + if (currProps.voters.chosen) { + if (currProps.voters.correct) { + container.classList.add('-correct'); + getInterface(answer).update(true); + } else { + container.classList.add('-incorrect'); + getInterface(answer).update(false); + } + } else if (currProps.voters.correct) { + getInterface(answer).update(true); + } + } + if (currProps.answered && prevProps !== currProps) { + requestAnimationFrame(rafCallback); } else { update(1); } }; - updateOption(initialVoters, false, answered, 0, totalVoters); + updateOption(currProps); return useInterface(container, { updateOption, diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index 628d5ce2..3c8bddd3 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -1,4 +1,5 @@ .poll { + max-width: 300px; &__question { font-weight: 500; } diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index c0a72a10..b6be2a73 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -1,11 +1,13 @@ -import { Poll, PollResults, PollAnswerVoters, Peer } from 'mtproto-js'; +import { Poll, PollResults, PollAnswerVoters, Peer, Message } from 'mtproto-js'; import { div, text, span } from 'core/html'; import { mount } from 'core/dom'; import { useWhileMounted, getInterface } from 'core/hooks'; import { polls } from 'services'; +import { messageCache } from 'cache'; +import { peerMessageToId } from 'helpers/api'; +import pollOption, { PollOptionInterface } from './poll-option'; import './poll.scss'; -import pollOption, { PollOptionInterface } from './poll-option'; const decoder = new TextDecoder(); function pollType(pollData: Poll) { @@ -21,18 +23,24 @@ function pollType(pollData: Poll) { return 'Anonymous Poll'; } -export default function poll(peer: Peer, messageId: number, pollData: Poll, results: PollResults, info: HTMLElement) { +export default function poll(peer: Peer, message: Message, info: HTMLElement) { + if (message._ !== 'message' || message.media?._ !== 'messageMediaPoll') { + throw new Error('message media must be of type "messageMediaPoll"'); + } + const { poll: pollData, results } = message.media; const pollOptions = div`.poll__options`(); const totalVotersText = text(''); - const container = span`.poll`( + const pollTypeText = text(pollType(pollData)); + const container = div`.poll`( div`.poll__question`(text(pollData.question)), - div`poll__type`(text(pollType(pollData))), + div`poll__type`(pollTypeText), pollOptions, span`.poll__voters`(totalVotersText), info, ); const options = new Map(); const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; + const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; pollData.answers.forEach((answer) => { const optionKey = decoder.decode(answer.option); let voters: PollAnswerVoters | undefined; @@ -41,13 +49,15 @@ export default function poll(peer: Peer, messageId: number, pollData: Poll, resu } const option = pollOption({ quiz: pollData.quiz ?? false, - multiple: pollData.multiple_choice ?? false, + multipleChoice: pollData.multiple_choice ?? false, option: answer, answered, - initialVoters: voters, - initialTotalVoters: results.total_voters ?? 0, + closed: pollData.closed ?? false, + voters, + maxVoters, + totalVoters: results.total_voters ?? 0, clickCallback: async (reset: () => void) => { - await polls.sendVote(peer, messageId, [answer.option]); + await polls.sendVote(peer, message.id, [answer.option]); reset(); }, }); @@ -61,28 +71,37 @@ export default function poll(peer: Peer, messageId: number, pollData: Poll, resu : 'No voters yet'; }; - const updatePollResults = (pollResults: PollResults, initial: boolean) => { - const totalVoters = pollResults.total_voters ?? 0; - updateTotalVotersText(totalVoters); - if (pollResults.results) { - const maxVoters = Math.max(...pollResults.results.map((r) => r.voters)); - const updateAnswered = pollResults.results.findIndex((r) => r.chosen) >= 0; - pollResults.results.forEach((r) => { + const updatePollResults = (updatedPoll: Poll | undefined, updatedResults: PollResults) => { + const updateTotalVoters = updatedResults.total_voters ?? 0; + updateTotalVotersText(updateTotalVoters); + if (updatedPoll) { + pollTypeText.textContent = pollType(updatedPoll); + } + if (updatedResults.results) { + const updateMaxVoters = Math.max(...updatedResults.results.map((r) => r.voters)); + const updateAnswered = updatedResults.results.findIndex((r) => r.chosen) >= 0; + updatedResults.results.forEach((r) => { const op = options.get(decoder.decode(r.option)); if (op) { - getInterface(op).updateOption(r, !initial, updateAnswered, maxVoters, totalVoters); + getInterface(op).updateOption({ + voters: r, + answered: updateAnswered, + closed: updatedPoll?.closed, + maxVoters: updateMaxVoters, + totalVoters: updateTotalVoters, + }); } }); } }; - updatePollResults(results, true); + updateTotalVotersText(results.total_voters ?? 0); - const updateListener = (update: PollResults, initial: boolean) => { - updatePollResults(update, initial); - }; - - useWhileMounted(container, () => polls.subscribe(peer, messageId, updateListener)); + useWhileMounted(container, () => messageCache.watchItem(peerMessageToId(peer, message.id), (item) => { + if (item?._ === 'message' && item.media?._ === 'messageMediaPoll') { + updatePollResults(item.media.poll, item.media.results); + } + })); return container; } diff --git a/src/components/message/message.ts b/src/components/message/message.ts index 85f4fc5c..3f201e9b 100644 --- a/src/components/message/message.ts +++ b/src/components/message/message.ts @@ -160,7 +160,7 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: if (msg.media._ === 'messageMediaDocument' && msg.media.document?._ === 'document' && getAttributeVideo(msg.media.document)) { const extraClass = hasMessage ? 'with-photo' : 'only-photo'; const previewEl = videoPreview(msg.media.document, { - fit: 'contain', width: 320, height: 320, minHeight: 60, minWidth: msg.message ? 320 : undefined + fit: 'contain', width: 320, height: 320, minHeight: 60, minWidth: msg.message ? 320 : undefined, }, peer, msg); if (!hasMessage && previewEl instanceof Element) previewEl.classList.add('raw'); @@ -213,7 +213,7 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: { out, className: extraClass }, reply, div`.message__text`( - poll(peer, msg.id, msg.media.poll, msg.media.results, info), + poll(peer, msg, info), ), ), info, diff --git a/src/services/polls.ts b/src/services/polls.ts index 27b784b2..8c984b72 100644 --- a/src/services/polls.ts +++ b/src/services/polls.ts @@ -1,52 +1,13 @@ import client from 'client/client'; -import { Update, PollResults, Peer } from 'mtproto-js'; +import { Update, Peer } from 'mtproto-js'; import { peerToInputPeer } from 'cache/accessors'; import { messageCache } from 'cache'; -import { peerMessageToId } from 'helpers/api'; - -type PollListener = (update: PollResults.pollResults, initial: boolean) => void; export default class PollsService { - private subscriptions = new Map>(); - private pollMessages = new Map(); - constructor() { client.updates.on('updateMessagePoll', this.processUpdate); } - private getMediaPoll(peer: Peer, messageId: number) { - const message = messageCache.get(peerMessageToId(peer, messageId)); - if (message?._ === 'message' && message.media?._ === 'messageMediaPoll') { - return message.media; - } - return undefined; - } - - public subscribe(peer: Peer, messageId: number, listener: PollListener) { - const mediaPoll = this.getMediaPoll(peer, messageId); - if (!mediaPoll) return () => { }; - let pollMessages = this.pollMessages.get(mediaPoll.poll.id); - if (!pollMessages) { - this.pollMessages.set(mediaPoll.poll.id, pollMessages = []); - } - pollMessages.push({ peer, messageId }); - let supscriptionSet = this.subscriptions.get(mediaPoll.poll.id); - if (!supscriptionSet) { - this.subscriptions.set(mediaPoll.poll.id, supscriptionSet = new Set()); - } - supscriptionSet.add(listener); - return () => this.unsubscribe(peer, messageId, listener); - } - - private unsubscribe(peer: Peer, messageId: number, listener: PollListener) { - const mediaPoll = this.getMediaPoll(peer, messageId); - if (!mediaPoll) return; - const subscriptionSet = this.subscriptions.get(mediaPoll.poll.id); - if (subscriptionSet) { - subscriptionSet.delete(listener); - } - } - public async sendVote(peer: Peer, messageId: number, options: ArrayBuffer[]) { const updates = await client.call('messages.sendVote', { peer: peerToInputPeer(peer), @@ -63,29 +24,6 @@ export default class PollsService { } private processUpdate = (update: Update.updateMessagePoll) => { - const messages = this.pollMessages.get(update.poll_id); - if (messages) { - messages.forEach((messageId) => { - const id = peerMessageToId(messageId.peer, messageId.messageId); - const message = messageCache.get(id); - if (message?._ === 'message' && message.media?._ === 'messageMediaPoll') { - const updatedMedia = { - ...message.media, - results: update.results, - }; - if (update.poll) { - updatedMedia.poll = update.poll; - } - const updatedMessage = { - media: updatedMedia, - }; - messageCache.change(id, updatedMessage); - } - }); - } - const subscriptionSet = this.subscriptions.get(update.poll_id); - if (subscriptionSet) { - subscriptionSet.forEach((listener) => listener(update.results, false)); - } + messageCache.indices.polls.updatePoll(update); }; } From c26858b5ad2461174d251b76d63151b31cd31d6c Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sun, 10 May 2020 18:38:35 +0300 Subject: [PATCH 13/26] Display recent voters avatars --- src/components/media/poll/poll.scss | 31 +++++++++++++++++++++++++++-- src/components/media/poll/poll.ts | 19 +++++++++++++++--- src/services/polls.ts | 4 +++- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index 3c8bddd3..624aa05e 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -4,12 +4,39 @@ font-weight: 500; } - &__type { + & &__info { + display: flex; + align-items: center; + } + + & &__type { color: var(--accent-color-inactive); font-size: 0.9rem; } - &__voters { + & &__recent-voters { + padding-left: 8px; + display: flex; + } + + & &__avatar-wrapper { + position: relative; + width: 13px; + height: 18px; + .avatar { + position: absolute; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + border: 1px solid var(--bubble-background); + } + .avatar.-standard { + font-size: 0.6rem; + } + } + + & &__voters { color: var(--accent-color-inactive); font-size: 0.9rem; } diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index b6be2a73..f05d144a 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -1,10 +1,11 @@ import { Poll, PollResults, PollAnswerVoters, Peer, Message } from 'mtproto-js'; import { div, text, span } from 'core/html'; -import { mount } from 'core/dom'; +import { mount, unmountChildren } from 'core/dom'; import { useWhileMounted, getInterface } from 'core/hooks'; import { polls } from 'services'; import { messageCache } from 'cache'; -import { peerMessageToId } from 'helpers/api'; +import { peerMessageToId, userIdToPeer } from 'helpers/api'; +import { profileAvatar } from 'components/profile'; import pollOption, { PollOptionInterface } from './poll-option'; import './poll.scss'; @@ -23,6 +24,13 @@ function pollType(pollData: Poll) { return 'Anonymous Poll'; } +function buildRecentVotersList(userIds?: number[]) { + if (userIds) { + return userIds.map((userId) => div`.poll__avatar-wrapper`(profileAvatar(userIdToPeer(userId)))); + } + return []; +} + export default function poll(peer: Peer, message: Message, info: HTMLElement) { if (message._ !== 'message' || message.media?._ !== 'messageMediaPoll') { throw new Error('message media must be of type "messageMediaPoll"'); @@ -31,9 +39,10 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { const pollOptions = div`.poll__options`(); const totalVotersText = text(''); const pollTypeText = text(pollType(pollData)); + const recentVoters = div`poll__recent-voters`(...buildRecentVotersList(results.recent_voters)); const container = div`.poll`( div`.poll__question`(text(pollData.question)), - div`poll__type`(pollTypeText), + div`poll__info`(div`poll__type`(pollTypeText), recentVoters), pollOptions, span`.poll__voters`(totalVotersText), info, @@ -78,6 +87,10 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { pollTypeText.textContent = pollType(updatedPoll); } if (updatedResults.results) { + unmountChildren(recentVoters); + buildRecentVotersList(results.recent_voters).forEach((avatar) => { + mount(recentVoters, avatar); + }); const updateMaxVoters = Math.max(...updatedResults.results.map((r) => r.voters)); const updateAnswered = updatedResults.results.findIndex((r) => r.chosen) >= 0; updatedResults.results.forEach((r) => { diff --git a/src/services/polls.ts b/src/services/polls.ts index 8c984b72..dd5a6b63 100644 --- a/src/services/polls.ts +++ b/src/services/polls.ts @@ -1,7 +1,7 @@ import client from 'client/client'; import { Update, Peer } from 'mtproto-js'; import { peerToInputPeer } from 'cache/accessors'; -import { messageCache } from 'cache'; +import { messageCache, userCache, chatCache } from 'cache'; export default class PollsService { constructor() { @@ -15,6 +15,8 @@ export default class PollsService { options, }); if (updates._ === 'updates') { + userCache.put(updates.users); + chatCache.put(updates.chats); updates.updates.forEach((update: Update) => { if (update._ === 'updateMessagePoll') { this.processUpdate(update); From 2f384cf79e9d6a46e797efbaae3c045626ca0850 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sun, 10 May 2020 18:44:07 +0300 Subject: [PATCH 14/26] fix --- src/components/media/poll/poll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index f05d144a..81b72ce6 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -88,7 +88,7 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { } if (updatedResults.results) { unmountChildren(recentVoters); - buildRecentVotersList(results.recent_voters).forEach((avatar) => { + buildRecentVotersList(updatedResults.recent_voters).forEach((avatar) => { mount(recentVoters, avatar); }); const updateMaxVoters = Math.max(...updatedResults.results.map((r) => r.voters)); From 7f0aef5f400286abdc07727c800f0b7750b18a50 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sun, 10 May 2020 21:21:21 +0300 Subject: [PATCH 15/26] Added multi-choice support --- src/components/media/poll/poll-checkbox.ts | 35 ++++++++---- src/components/media/poll/poll-option.ts | 9 +++- src/components/media/poll/poll.scss | 14 +++++ src/components/media/poll/poll.ts | 63 ++++++++++++++++------ src/components/message/short.ts | 2 +- 5 files changed, 93 insertions(+), 30 deletions(-) diff --git a/src/components/media/poll/poll-checkbox.ts b/src/components/media/poll/poll-checkbox.ts index 2cdd5541..134f909c 100644 --- a/src/components/media/poll/poll-checkbox.ts +++ b/src/components/media/poll/poll-checkbox.ts @@ -2,34 +2,47 @@ import { checkbox } from 'components/ui'; import { div } from 'core/html'; import { mount, unmount } from 'core/dom'; import { svgCodeToComponent } from 'core/factory'; -import { getInterface } from 'core/hooks'; +import { getInterface, useInterface } from 'core/hooks'; import spinnerCode from './spinner.svg?raw'; import './poll-checkbox.scss'; const spinnerSvg = svgCodeToComponent(spinnerCode); -export default function pollCheckbox(multiple: boolean, clickCallback: (reset: () => void) => void) { +type Props = { + multiple: boolean, + clickCallback: (selected: boolean) => void, +}; + +export default function pollCheckbox({ multiple, clickCallback }: Props) { if (multiple) { - return div`.pollCheckbox`(checkbox()); + const cb = checkbox({ + onChange: (checked) => clickCallback(checked), + }); + return useInterface(div`.pollCheckbox`(cb), { + reset: () => getInterface(cb).setChecked(false), + }); } + const container = div`.pollCheckbox`(); + const spinner = spinnerSvg(); const cb = checkbox({ - onChange: () => { + onChange: (checked) => { unmount(cb); const effect = div`.pollCheckbox__ripple`({ onAnimationEnd: () => setTimeout(() => unmount(effect), 1000), }); mount(container, effect); - const spinner = spinnerSvg(); mount(container, spinner); - clickCallback(() => { - unmount(spinner); - getInterface(cb).setChecked(false); - mount(container, cb); - }); + clickCallback(checked); }, }); mount(container, cb); - return container; + return useInterface(container, { + reset: () => { + unmount(spinner); + getInterface(cb).setChecked(false); + mount(container, cb); + }, + }); } diff --git a/src/components/media/poll/poll-option.ts b/src/components/media/poll/poll-option.ts index f01a1a22..b95b0a33 100644 --- a/src/components/media/poll/poll-option.ts +++ b/src/components/media/poll/poll-option.ts @@ -16,7 +16,7 @@ type Props = { voters?: PollAnswerVoters, maxVoters: number, totalVoters: number, - clickCallback: (reset: () => void) => void, + clickCallback: (selected: boolean) => void, }; function answerIcon() { @@ -38,7 +38,11 @@ export default function pollOption(initialProps: Props) { let firstAnimationCompleted = initialProps.answered; let path: SVGPathElement; - const checkbox = span`.pollOption__checkbox`(pollCheckbox(currProps.multipleChoice, currProps.clickCallback)); + const checkboxEl = pollCheckbox({ + multiple: currProps.multipleChoice, + clickCallback: currProps.clickCallback, + }); + const checkbox = span`.pollOption__checkbox`(checkboxEl); const percentage = span`.pollOption__percentage`(); const answer = answerIcon(); const line = svgEl('svg', { width: 300, height: 30, class: 'pollOption__line' }, [ @@ -119,6 +123,7 @@ export default function pollOption(initialProps: Props) { return useInterface(container, { updateOption, + reset: () => getInterface(checkboxEl).reset(), }); } diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index 624aa05e..438d436b 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -1,4 +1,5 @@ .poll { + user-select: none; max-width: 300px; &__question { font-weight: 500; @@ -40,4 +41,17 @@ color: var(--accent-color-inactive); font-size: 0.9rem; } + + & &__vote-button { + text-align: center; + color: var(--accent-color); + transition: color 0.3s; + transform: translateY(10px); + cursor: pointer; + } + + & &__vote-button.-inactive { + color: var(--accent-color-inactive); + cursor: default; + } } diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 81b72ce6..100ccb6c 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -11,6 +11,8 @@ import pollOption, { PollOptionInterface } from './poll-option'; import './poll.scss'; const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + function pollType(pollData: Poll) { if (pollData.closed) { return 'Final Results'; @@ -36,17 +38,23 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { throw new Error('message media must be of type "messageMediaPoll"'); } const { poll: pollData, results } = message.media; - const pollOptions = div`.poll__options`(); + const selectedOptions = new Set(); + const pollOptions: ReturnType[] = []; const totalVotersText = text(''); const pollTypeText = text(pollType(pollData)); const recentVoters = div`poll__recent-voters`(...buildRecentVotersList(results.recent_voters)); - const container = div`.poll`( - div`.poll__question`(text(pollData.question)), - div`poll__info`(div`poll__type`(pollTypeText), recentVoters), - pollOptions, - span`.poll__voters`(totalVotersText), - info, - ); + const submitOptions = async () => { + const optionsArray: ArrayBuffer[] = []; + selectedOptions.forEach((o) => optionsArray.push(encoder.encode(o).buffer)); + await polls.sendVote(peer, message.id, optionsArray); + selectedOptions.clear(); + pollOptions.forEach((po) => getInterface(po).reset()); + }; + const voteButton = div`.poll__vote-button.-inactive`({ + onClick: async () => { + await submitOptions(); + }, + }, text('Vote')); const options = new Map(); const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; @@ -65,24 +73,37 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { voters, maxVoters, totalVoters: results.total_voters ?? 0, - clickCallback: async (reset: () => void) => { - await polls.sendVote(peer, message.id, [answer.option]); - reset(); + clickCallback: async (selected) => { + if (pollData.multiple_choice) { + const optKey = decoder.decode(answer.option); + if (selected) { + selectedOptions.add(optKey); + } else { + selectedOptions.delete(optKey); + } + voteButton.classList.toggle('-inactive', selectedOptions.size === 0); + } else { + selectedOptions.add(decoder.decode(answer.option)); + await submitOptions(); + } }, }); options.set(optionKey, option); - mount(pollOptions, option); + pollOptions.push(option); }); - const updateTotalVotersText = (totalVoters: number) => { + const updateTotalVotersText = (closed: boolean, totalVoters: number) => { totalVotersText.textContent = totalVoters > 0 ? `${totalVoters} voter${totalVoters > 1 ? 's' : ''}` - : 'No voters yet'; + : `No voters${closed ? '' : ' yet'}`; + }; + const updateVoteButtonText = (isAnswered: boolean) => { + voteButton.textContent = isAnswered ? 'View Results' : 'Vote'; }; const updatePollResults = (updatedPoll: Poll | undefined, updatedResults: PollResults) => { const updateTotalVoters = updatedResults.total_voters ?? 0; - updateTotalVotersText(updateTotalVoters); + updateTotalVotersText(updatedPoll?.closed ?? false, updateTotalVoters); if (updatedPoll) { pollTypeText.textContent = pollType(updatedPoll); } @@ -93,6 +114,7 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { }); const updateMaxVoters = Math.max(...updatedResults.results.map((r) => r.voters)); const updateAnswered = updatedResults.results.findIndex((r) => r.chosen) >= 0; + updateVoteButtonText(updateAnswered); updatedResults.results.forEach((r) => { const op = options.get(decoder.decode(r.option)); if (op) { @@ -108,7 +130,16 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { } }; - updateTotalVotersText(results.total_voters ?? 0); + updateTotalVotersText(pollData.closed ?? false, results.total_voters ?? 0); + updateVoteButtonText(answered); + + const container = div`.poll`( + div`.poll__question`(text(pollData.question)), + div`poll__info`(div`poll__type`(pollTypeText), recentVoters), + div`.poll__options`(...pollOptions), + pollData.multiple_choice ? voteButton : span`.poll__voters`(totalVotersText), + info, + ); useWhileMounted(container, () => messageCache.watchItem(peerMessageToId(peer, message.id), (item) => { if (item?._ === 'message' && item.media?._ === 'messageMediaPoll') { diff --git a/src/components/message/short.ts b/src/components/message/short.ts index 80b5372e..88ab1bdb 100644 --- a/src/components/message/short.ts +++ b/src/components/message/short.ts @@ -40,7 +40,7 @@ export default function messageShort(msg: Message) { case 'messageMediaGeo': return '📍 Location'; case 'messageMediaContact': return '👤 Contact'; case 'messageMediaGeoLive': return '📍 Live Location'; - case 'messageMediaPoll': return '📊 Poll'; + case 'messageMediaPoll': return `📊 ${msg.media.poll.question}`; case 'messageMediaDocument': if (msg.media.document?._ === 'document') { const isSticker = getAttributeSticker(msg.media.document); From cbb5e1592d3471b5b2e7a8f5ebcebdd6f767baaa Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sun, 10 May 2020 21:53:06 +0300 Subject: [PATCH 16/26] Minor improvements --- src/components/media/poll/poll-option.scss | 6 +++++ src/components/media/poll/poll-option.ts | 29 ++++++++++++++-------- src/components/media/poll/poll.scss | 1 + src/components/media/poll/poll.ts | 3 ++- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/components/media/poll/poll-option.scss b/src/components/media/poll/poll-option.scss index 225bbd0d..3bcdb13b 100644 --- a/src/components/media/poll/poll-option.scss +++ b/src/components/media/poll/poll-option.scss @@ -53,6 +53,8 @@ display: grid; align-items: center; justify-items: center; + opacity: 1; + transition: opacity 0.3s 0.2s; svg { width: 10px; height: 10px; @@ -64,6 +66,10 @@ } } + & &__answer.-hidden { + opacity: 0; + } + & &__line { grid-row: 1; grid-column: 1 / 2; diff --git a/src/components/media/poll/poll-option.ts b/src/components/media/poll/poll-option.ts index b95b0a33..59f194d7 100644 --- a/src/components/media/poll/poll-option.ts +++ b/src/components/media/poll/poll-option.ts @@ -20,12 +20,12 @@ type Props = { }; function answerIcon() { - const container = div`.pollOption__answer`({ style: { display: 'none' } }); + const container = div`.pollOption__answer.-hidden`(); return useInterface(container, { update: (isCorrect?: boolean) => { - unmountChildren(container); + container.classList.toggle('-hidden', isCorrect === undefined); if (isCorrect !== undefined) { - container.style.removeProperty('display'); + unmountChildren(container); mount(container, isCorrect ? checkIcon() : closeIcon()); } }, @@ -99,16 +99,23 @@ export default function pollOption(initialProps: Props) { checkbox.classList.toggle('-answered', answered); percentage.classList.toggle('-answered', answered); line.classList.toggle('-answered', answered); - if (currProps.quiz && currProps.voters) { - if (currProps.voters.chosen) { - if (currProps.voters.correct) { - container.classList.add('-correct'); + getInterface(answer).update(); + container.classList.remove('-correct'); + container.classList.remove('-incorrect'); + if (currProps.voters) { + if (currProps.quiz) { + if (currProps.voters.chosen) { + if (currProps.voters.correct) { + container.classList.add('-correct'); + getInterface(answer).update(true); + } else { + container.classList.add('-incorrect'); + getInterface(answer).update(false); + } + } else if (currProps.voters.correct) { getInterface(answer).update(true); - } else { - container.classList.add('-incorrect'); - getInterface(answer).update(false); } - } else if (currProps.voters.correct) { + } else if (currProps.voters.chosen) { getInterface(answer).update(true); } } diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index 438d436b..a0521c82 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -18,6 +18,7 @@ & &__recent-voters { padding-left: 8px; display: flex; + flex-direction: row-reverse; } & &__avatar-wrapper { diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 100ccb6c..696386a8 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -28,7 +28,8 @@ function pollType(pollData: Poll) { function buildRecentVotersList(userIds?: number[]) { if (userIds) { - return userIds.map((userId) => div`.poll__avatar-wrapper`(profileAvatar(userIdToPeer(userId)))); + const reversedIds = [...userIds].reverse(); + return reversedIds.map((userId) => div`.poll__avatar-wrapper`(profileAvatar(userIdToPeer(userId)))); } return []; } From 73078eae771db30aa9a5efe9064dd6c4ee3c8f84 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sun, 10 May 2020 22:32:33 +0300 Subject: [PATCH 17/26] Minor fixes --- src/components/media/poll/poll-option.scss | 10 +++++----- src/components/media/poll/poll.ts | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/components/media/poll/poll-option.scss b/src/components/media/poll/poll-option.scss index 3bcdb13b..473f4382 100644 --- a/src/components/media/poll/poll-option.scss +++ b/src/components/media/poll/poll-option.scss @@ -98,9 +98,9 @@ &__line.-answered { opacity: 1; } -} -.checkbox__input:checked + .checkbox__box { - border-color: var(--accent-color); - background: var(--accent-color); -} \ No newline at end of file + .checkbox__input:checked + .checkbox__box { + border-color: var(--accent-color); + background: var(--accent-color); + } +} diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 696386a8..e36c73da 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -28,8 +28,7 @@ function pollType(pollData: Poll) { function buildRecentVotersList(userIds?: number[]) { if (userIds) { - const reversedIds = [...userIds].reverse(); - return reversedIds.map((userId) => div`.poll__avatar-wrapper`(profileAvatar(userIdToPeer(userId)))); + return userIds.map((userId) => div`.poll__avatar-wrapper`(profileAvatar(userIdToPeer(userId)))).reverse(); } return []; } From 68b6d7488bf109aae0935c3dc699a04d4d2d38e5 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Mon, 11 May 2020 22:15:21 +0300 Subject: [PATCH 18/26] Files renamed --- src/components/media/poll/poll.ts | 2 +- .../media/poll/{poll-checkbox.scss => poll_checkbox.scss} | 0 .../media/poll/{poll-checkbox.ts => poll_checkbox.ts} | 2 +- .../media/poll/{poll-option.scss => poll_option.scss} | 0 src/components/media/poll/{poll-option.ts => poll_option.ts} | 4 ++-- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/components/media/poll/{poll-checkbox.scss => poll_checkbox.scss} (100%) rename src/components/media/poll/{poll-checkbox.ts => poll_checkbox.ts} (97%) rename src/components/media/poll/{poll-option.scss => poll_option.scss} (100%) rename src/components/media/poll/{poll-option.ts => poll_option.ts} (98%) diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index e36c73da..d732218c 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -6,7 +6,7 @@ import { polls } from 'services'; import { messageCache } from 'cache'; import { peerMessageToId, userIdToPeer } from 'helpers/api'; import { profileAvatar } from 'components/profile'; -import pollOption, { PollOptionInterface } from './poll-option'; +import pollOption, { PollOptionInterface } from './poll_option'; import './poll.scss'; diff --git a/src/components/media/poll/poll-checkbox.scss b/src/components/media/poll/poll_checkbox.scss similarity index 100% rename from src/components/media/poll/poll-checkbox.scss rename to src/components/media/poll/poll_checkbox.scss diff --git a/src/components/media/poll/poll-checkbox.ts b/src/components/media/poll/poll_checkbox.ts similarity index 97% rename from src/components/media/poll/poll-checkbox.ts rename to src/components/media/poll/poll_checkbox.ts index 134f909c..e0713a9e 100644 --- a/src/components/media/poll/poll-checkbox.ts +++ b/src/components/media/poll/poll_checkbox.ts @@ -5,7 +5,7 @@ import { svgCodeToComponent } from 'core/factory'; import { getInterface, useInterface } from 'core/hooks'; import spinnerCode from './spinner.svg?raw'; -import './poll-checkbox.scss'; +import './poll_checkbox.scss'; const spinnerSvg = svgCodeToComponent(spinnerCode); diff --git a/src/components/media/poll/poll-option.scss b/src/components/media/poll/poll_option.scss similarity index 100% rename from src/components/media/poll/poll-option.scss rename to src/components/media/poll/poll_option.scss diff --git a/src/components/media/poll/poll-option.ts b/src/components/media/poll/poll_option.ts similarity index 98% rename from src/components/media/poll/poll-option.ts rename to src/components/media/poll/poll_option.ts index 59f194d7..ef22c151 100644 --- a/src/components/media/poll/poll-option.ts +++ b/src/components/media/poll/poll_option.ts @@ -3,9 +3,9 @@ import { PollAnswer, PollAnswerVoters } from 'mtproto-js'; import { text, span, div } from 'core/html'; import { svgEl, unmountChildren, mount } from 'core/dom'; import { close as closeIcon, check as checkIcon } from 'components/icons'; -import pollCheckbox from './poll-checkbox'; +import pollCheckbox from './poll_checkbox'; -import './poll-option.scss'; +import './poll_option.scss'; type Props = { quiz: boolean, From abba977ea031c6f20bf9633dbdba5e8956c9edcd Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Tue, 12 May 2020 02:27:47 +0300 Subject: [PATCH 19/26] Poll results popup --- src/components/media/poll/poll.ts | 21 ++-- .../poll_results/poll_result_option.scss | 33 ++++++ .../popup/poll_results/poll_result_option.ts | 25 +++++ .../popup/poll_results/poll_results.scss | 18 +++ .../popup/poll_results/poll_results.ts | 105 ++++++++++++++++++ src/components/popup/popup.ts | 7 ++ src/components/profile/avatar/avatar.scss | 1 + src/components/sidebar/search/search.ts | 7 +- src/components/ui/peer_status/peer_status.ts | 6 +- src/helpers/other.ts | 4 + src/services/main.ts | 3 +- src/services/polls.ts | 11 +- 12 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 src/components/popup/poll_results/poll_result_option.scss create mode 100644 src/components/popup/poll_results/poll_result_option.ts create mode 100644 src/components/popup/poll_results/poll_results.scss create mode 100644 src/components/popup/poll_results/poll_results.ts diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index d732218c..aae36268 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -2,13 +2,14 @@ import { Poll, PollResults, PollAnswerVoters, Peer, Message } from 'mtproto-js'; import { div, text, span } from 'core/html'; import { mount, unmountChildren } from 'core/dom'; import { useWhileMounted, getInterface } from 'core/hooks'; -import { polls } from 'services'; +import { polls, main } from 'services'; import { messageCache } from 'cache'; import { peerMessageToId, userIdToPeer } from 'helpers/api'; import { profileAvatar } from 'components/profile'; import pollOption, { PollOptionInterface } from './poll_option'; import './poll.scss'; +import { pluralize } from 'helpers/other'; const decoder = new TextDecoder(); const encoder = new TextEncoder(); @@ -33,8 +34,8 @@ function buildRecentVotersList(userIds?: number[]) { return []; } -export default function poll(peer: Peer, message: Message, info: HTMLElement) { - if (message._ !== 'message' || message.media?._ !== 'messageMediaPoll') { +export default function poll(peer: Peer, message: Message.message, info: HTMLElement) { + if (message.media?._ !== 'messageMediaPoll') { throw new Error('message media must be of type "messageMediaPoll"'); } const { poll: pollData, results } = message.media; @@ -50,14 +51,18 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { selectedOptions.clear(); pollOptions.forEach((po) => getInterface(po).reset()); }; + const options = new Map(); + const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; + const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; const voteButton = div`.poll__vote-button.-inactive`({ onClick: async () => { - await submitOptions(); + if (answered) { + main.showPopup('pollResults', { peer, message, poll: pollData }); + } else { + await submitOptions(); + } }, }, text('Vote')); - const options = new Map(); - const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; - const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; pollData.answers.forEach((answer) => { const optionKey = decoder.decode(answer.option); let voters: PollAnswerVoters | undefined; @@ -94,7 +99,7 @@ export default function poll(peer: Peer, message: Message, info: HTMLElement) { const updateTotalVotersText = (closed: boolean, totalVoters: number) => { totalVotersText.textContent = totalVoters > 0 - ? `${totalVoters} voter${totalVoters > 1 ? 's' : ''}` + ? `${totalVoters} ${pluralize(totalVoters, 'voter', 'voters')}` : `No voters${closed ? '' : ' yet'}`; }; const updateVoteButtonText = (isAnswered: boolean) => { diff --git a/src/components/popup/poll_results/poll_result_option.scss b/src/components/popup/poll_results/poll_result_option.scss new file mode 100644 index 00000000..91895bba --- /dev/null +++ b/src/components/popup/poll_results/poll_result_option.scss @@ -0,0 +1,33 @@ +.pollResultOption { + display: flex; + flex-direction: column; + border-radius: 10px; + background-color: rgba(0, 0, 0, 0.04); + margin-top: 8px; + padding: 8px; + + &__header { + display: grid; + grid-template-columns: 1fr auto; + } + + &__voters-list { + display: flex; + flex-direction: column; + } + + &__voter { + display: flex; + align-items: center; + margin-top: 10px; + + .avatar { + max-width: 25px; + max-height: 25px; + } + } + + &__voter-name { + margin-left: 10px; + } +} diff --git a/src/components/popup/poll_results/poll_result_option.ts b/src/components/popup/poll_results/poll_result_option.ts new file mode 100644 index 00000000..29b2f108 --- /dev/null +++ b/src/components/popup/poll_results/poll_result_option.ts @@ -0,0 +1,25 @@ +import { div, text } from 'core/html'; +import { useInterface } from 'core/hooks'; +import { pluralize } from 'helpers/other'; +import { userIdToPeer } from 'helpers/api'; +import { mount } from 'core/dom'; +import { profileAvatar, profileTitle } from 'components/profile'; + +import './poll_result_option.scss'; + +export default function pollResultOption(option: ArrayBuffer, optionText: string, quiz: boolean) { + const optionTextEl = div`.pollResultOption__text`(text(optionText)); + const votersCountEl = text(''); + const votersListEl = div`.pollResultOption__voters-list`(); + const container = div`.pollResultOption`(div`.pollResultOption__header`(optionTextEl, votersCountEl), votersListEl); + return useInterface(container, { + setVoters: (voters: number, totalVoters: number) => { + optionTextEl.textContent = `${optionText} \u2014 ${Math.round((voters / totalVoters) * 100)}%`; + votersCountEl.textContent = `${voters} ${quiz ? pluralize(voters, 'answer', 'answers') : pluralize(voters, 'vote', 'votes')}`; + }, + setVoter: (userId: number, voted: boolean) => { + const peer = userIdToPeer(userId); + mount(votersListEl, div`.pollResultOption__voter`(profileAvatar(userIdToPeer(userId)), div`.pollResultOption__voter-name`(profileTitle(peer)))); + }, + }); +} diff --git a/src/components/popup/poll_results/poll_results.scss b/src/components/popup/poll_results/poll_results.scss new file mode 100644 index 00000000..71edb595 --- /dev/null +++ b/src/components/popup/poll_results/poll_results.scss @@ -0,0 +1,18 @@ +.pollResultsPopup { + width: 400px; + display: flex; + flex-direction: column; + max-height: 80vh; + overflow: auto; + + &__loading { + width: 30px; + height: 30px; + color: #4ea4f5; + } + + &__question { + font-weight: 500; + font-size: 1.1em; + } +} diff --git a/src/components/popup/poll_results/poll_results.ts b/src/components/popup/poll_results/poll_results.ts new file mode 100644 index 00000000..502a65fd --- /dev/null +++ b/src/components/popup/poll_results/poll_results.ts @@ -0,0 +1,105 @@ +import { Poll, Peer, Message, MessageUserVote } from 'mtproto-js'; +import { peerToInputPeer } from 'cache/accessors'; +import { div, text } from 'core/html'; +import { listen } from 'core/dom'; +import { getInterface } from 'core/hooks'; +import client from 'client/client'; +import { userCache, messageCache } from 'cache'; +import { peerMessageToId } from 'helpers/api'; +import popupCommon from '../popup_common'; +import pollResultOption from './poll_result_option'; + +import './poll_results.scss'; + +export type PollResultsContext = { + peer: Peer, + message: Message.message, + poll: Poll, +}; + +const decoder = new TextDecoder(); + +export default function pollResultsPopup({ peer, message, poll }: PollResultsContext) { + // const loader = materialSpinner({ className: 'pollResultsPopup__loading' }); + const close = div`.popup__close`(); + const options = new Map(poll.answers.map((answer) => [ + decoder.decode(answer.option), + pollResultOption(answer.option, answer.text, poll.quiz ?? false), + ])); + const content = div`.popup__content.pollResultsPopup`( + div`pollResultsPopup__question`(text(poll.question)), + div`pollResultsPopup__options`(...options.values()), + // loader, + ); + const container = popupCommon( + div`.popup__header`( + div`.popup__title`(text(poll.quiz ? 'Quiz Results' : 'Poll Results')), + close, + ), + content, + ); + + listen(close, 'click', getInterface(container).remove); + + const updateOptions = (msg?: Message) => { + if (msg?._ === 'message' && msg.media?._ === 'messageMediaPoll' && msg.media.results.results) { + const totalVoters = msg.media.results.total_voters ?? 0; + msg.media.results.results.forEach((pollResult) => { + const option = options.get(decoder.decode(pollResult.option)); + if (option) { + getInterface(option).setVoters(pollResult.voters, totalVoters); + } + }); + } + }; + + messageCache.watchItem(peerMessageToId(peer, message.id), (msg) => { + updateOptions(msg); + }); + + updateOptions(message); + + const updateVote = (vote: MessageUserVote, selectedOption?: string) => { + const votedOptions = new Set(); + switch (vote._) { + case 'messageUserVote': + votedOptions.add(decoder.decode(vote.option)); + break; + case 'messageUserVoteMultiple': + vote.options.forEach((option) => votedOptions.add(decoder.decode(option))); + break; + case 'messageUserVoteInputOption': + votedOptions.add(selectedOption!); + break; + default: + } + options.forEach((option, key) => { + if (!selectedOption || key === selectedOption) { + const voted = votedOptions.has(key); + getInterface(option).setVoter(vote.user_id, voted); + } + }); + }; + + async function loadData() { + for (let index = 0; index < poll.answers.length; index++) { + const answer = poll.answers[index]; + const request = { + peer: peerToInputPeer(peer), + id: message.id, + limit: 50, + option: answer.option, + }; + // eslint-disable-next-line no-await-in-loop + const pollVotes = await client.call('messages.getPollVotes', request); + userCache.put(pollVotes.users); + pollVotes.votes.forEach((vote) => { + updateVote(vote, decoder.decode(answer.option)); + }); + } + } + + loadData(); + + return container; +} diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts index abc34d75..1ae587a1 100644 --- a/src/components/popup/popup.ts +++ b/src/components/popup/popup.ts @@ -6,6 +6,7 @@ import photoPopup from './photo/photo'; import SendMediaPopup from './send_media/send_media'; import stickerSetPopup from './sticker_set/sticker_set'; import videoPopup from './video/video'; +import pollResultsPopup from './poll_results/poll_results'; import './popup.scss'; /** @@ -46,6 +47,12 @@ export default function popup() { mount(wrapper, element = stickerSetPopup(main.popupCtx)); break; + case 'pollResults': { + wrapper.classList.add('opened'); + mount(wrapper, element = pollResultsPopup(main.popupCtx)); + break; + } + default: throw new Error('Unknown popup'); } diff --git a/src/components/profile/avatar/avatar.scss b/src/components/profile/avatar/avatar.scss index 7400a780..6723dc63 100644 --- a/src/components/profile/avatar/avatar.scss +++ b/src/components/profile/avatar/avatar.scss @@ -8,6 +8,7 @@ height: 100%; border-radius: 50%; overflow: hidden; + user-select: none; &.-standard { color: #FFFFFF; diff --git a/src/components/sidebar/search/search.ts b/src/components/sidebar/search/search.ts index 6d18b790..a94f2044 100644 --- a/src/components/sidebar/search/search.ts +++ b/src/components/sidebar/search/search.ts @@ -4,10 +4,11 @@ import { message, messageSearch } from 'services'; import { isSearchRequestEmpty } from 'services/message_search/message_search_session'; import * as icons from 'components/icons'; import { roundButton, searchInput, VirtualizedList } from 'components/ui'; -import { getInterface, useToBehaviorSubject } from 'core/hooks'; -import { mount, watchVisibility } from 'core/dom'; +import { useToBehaviorSubject } from 'core/hooks'; +import { mount } from 'core/dom'; import { peerMessageToId } from 'helpers/api'; import { foundMessage } from 'components/sidebar'; +import { pluralize } from 'helpers/other'; import './search.scss'; type SidebarComponentProps = import('../sidebar').SidebarComponentProps; @@ -71,7 +72,7 @@ export default function messageSearchSidebar({ onBack }: SidebarComponentProps) if (result.count === 0) { return 'Nothing is found'; } - return `${result.count} message${result.count === 1 ? '' : 's'} found`; + return `${result.count} ${pluralize(result.count, 'message', 'messages')} found`; }))), )); mount(rootEl, resultList.container); diff --git a/src/components/ui/peer_status/peer_status.ts b/src/components/ui/peer_status/peer_status.ts index b4c54f2a..71d8dcac 100644 --- a/src/components/ui/peer_status/peer_status.ts +++ b/src/components/ui/peer_status/peer_status.ts @@ -6,7 +6,7 @@ import { el, mount, unmountChildren } from 'core/dom'; import { text, fragment } from 'core/html'; import { useObservable, useWhileMounted } from 'core/hooks'; import { auth as authService } from 'services'; -import { todoAssertHasValue } from 'helpers/other'; +import { todoAssertHasValue, pluralize } from 'helpers/other'; import { areUserStatusesEqual } from 'helpers/api'; import './peer_status.scss'; @@ -48,11 +48,11 @@ function formatLastSeenTime(date: number /* unix ms */) { } if (timeDiff < 60 * 60) { const minutes = Math.floor(timeDiff / 60); - return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + return `${minutes} ${pluralize(minutes, 'minute', 'minutes')} ago`; } if (timeDiff < 24 * 60 * 60) { const hours = Math.floor(timeDiff / 60 / 60); - return `${hours} hour${hours === 1 ? '' : 's'} ago`; + return `${hours} ${pluralize(hours, 'hour', 'hours')} ago`; } return formatDate(new Date(date)); } diff --git a/src/helpers/other.ts b/src/helpers/other.ts index 5e0ea18d..b030d7ee 100644 --- a/src/helpers/other.ts +++ b/src/helpers/other.ts @@ -50,3 +50,7 @@ export function formatNumber(n: number) { base = abbrev.indexOf(suffix) + 1; return suffix ? round(n / (1000 ** base), 1) + suffix : `${n}`; } + +export function pluralize(n: number, single: string, multiple: string) { + return Math.abs(n) !== 1 ? multiple : single; +} diff --git a/src/services/main.ts b/src/services/main.ts index 0cf443f3..d31702fe 100644 --- a/src/services/main.ts +++ b/src/services/main.ts @@ -1,6 +1,6 @@ import { BehaviorSubject } from 'rxjs'; import client from 'client/client'; -import { Photo, Message, Peer, InputStickerSet, Document } from 'mtproto-js'; +import { Photo, Message, Peer, InputStickerSet, Document, Poll } from 'mtproto-js'; import { PhotoOptions } from 'helpers/other'; type SidebarState = import('components/sidebar/sidebar').SidebarState; @@ -31,6 +31,7 @@ export default class MainService { showPopup(type: 'stickerSet', ctx: InputStickerSet): void; showPopup(type: 'photo', ctx: { rect: DOMRect, options: PhotoOptions, photo: Photo, peer: Peer, message: Message }): void; showPopup(type: 'video', ctx: { rect: DOMRect, video: Document.document, peer?: Peer, message?: Message }): void; + showPopup(type: 'pollResults', ctx: { peer: Peer, message: Message.message, poll: Poll }): void; showPopup(type: string, ctx?: any): void { this.popupCtx = ctx; this.popup.next(type); diff --git a/src/services/polls.ts b/src/services/polls.ts index dd5a6b63..84720410 100644 --- a/src/services/polls.ts +++ b/src/services/polls.ts @@ -5,7 +5,8 @@ import { messageCache, userCache, chatCache } from 'cache'; export default class PollsService { constructor() { - client.updates.on('updateMessagePoll', this.processUpdate); + client.updates.on('updateMessagePoll', this.processUpdateMessagePoll); + client.updates.on('updateMessagePollVote', this.processUpdateMessagePollVote); } public async sendVote(peer: Peer, messageId: number, options: ArrayBuffer[]) { @@ -19,13 +20,17 @@ export default class PollsService { chatCache.put(updates.chats); updates.updates.forEach((update: Update) => { if (update._ === 'updateMessagePoll') { - this.processUpdate(update); + this.processUpdateMessagePoll(update); } }); } } - private processUpdate = (update: Update.updateMessagePoll) => { + private processUpdateMessagePoll = (update: Update.updateMessagePoll) => { messageCache.indices.polls.updatePoll(update); }; + + private processUpdateMessagePollVote = (update: Update.updateMessagePollVote) => { + console.error(update); + }; } From 6f6501446720a6037fd658b30fa3397b4cc8dc74 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sat, 16 May 2020 00:13:53 +0300 Subject: [PATCH 20/26] wip --- src/components/media/poll/poll.scss | 30 +++++++++- src/components/media/poll/poll.ts | 53 ++++++++++-------- src/components/media/poll/poll_option.ts | 4 +- src/components/media/poll/vote_footer.ts | 71 ++++++++++++++++++++++++ src/components/message/message.scss | 5 +- src/components/message/message.ts | 6 +- 6 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 src/components/media/poll/vote_footer.ts diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index a0521c82..01e78e60 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -1,6 +1,26 @@ .poll { user-select: none; - max-width: 300px; + font-size: 1rem; + + & &__body { + padding: 7PX 10PX 0 10PX; + } + + & &__footer { + display: grid; + + >* { + grid-row: 1; + grid-column: 1; + } + } + + &__message-info { + justify-self: end; + align-self: end; + margin: 0 10PX 1PX 0 !important; + } + &__question { font-weight: 500; } @@ -25,6 +45,7 @@ position: relative; width: 13px; height: 18px; + .avatar { position: absolute; width: 18px; @@ -33,6 +54,7 @@ min-height: 18px; border: 1px solid var(--bubble-background); } + .avatar.-standard { font-size: 0.6rem; } @@ -41,13 +63,15 @@ & &__voters { color: var(--accent-color-inactive); font-size: 0.9rem; + padding: 10px 0 4px 10px; } & &__vote-button { - text-align: center; + margin: 8PX 0; + text-transform: uppercase; + text-align: center; color: var(--accent-color); transition: color 0.3s; - transform: translateY(10px); cursor: pointer; } diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index aae36268..53974d62 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -6,10 +6,11 @@ import { polls, main } from 'services'; import { messageCache } from 'cache'; import { peerMessageToId, userIdToPeer } from 'helpers/api'; import { profileAvatar } from 'components/profile'; +import { pluralize } from 'helpers/other'; import pollOption, { PollOptionInterface } from './poll_option'; +import voteFooter, { VoteButtonState } from './vote_footer'; import './poll.scss'; -import { pluralize } from 'helpers/other'; const decoder = new TextDecoder(); const encoder = new TextEncoder(); @@ -19,12 +20,9 @@ function pollType(pollData: Poll) { return 'Final Results'; } if (pollData.quiz) { - return 'Quiz'; - } - if (pollData.public_voters) { - return 'Poll'; + return pollData.public_voters ? 'Quiz' : 'Anonymous Quiz'; } - return 'Anonymous Poll'; + return pollData.public_voters ? 'Poll' : 'Anonymous Poll'; } function buildRecentVotersList(userIds?: number[]) { @@ -54,15 +52,12 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const options = new Map(); const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; - const voteButton = div`.poll__vote-button.-inactive`({ - onClick: async () => { - if (answered) { - main.showPopup('pollResults', { peer, message, poll: pollData }); - } else { - await submitOptions(); - } - }, - }, text('Vote')); + const voteFooterEl = voteFooter( + pollData.quiz ?? false, + pollData.public_voters ?? false, + pollData.multiple_choice ?? false, + () => { submitOptions(); }, + () => { main.showPopup('pollResults', { peer, message, poll: pollData }); }); pollData.answers.forEach((answer) => { const optionKey = decoder.decode(answer.option); let voters: PollAnswerVoters | undefined; @@ -86,7 +81,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle } else { selectedOptions.delete(optKey); } - voteButton.classList.toggle('-inactive', selectedOptions.size === 0); + voteFooterEl.classList.toggle('-inactive', selectedOptions.size === 0); } else { selectedOptions.add(decoder.decode(answer.option)); await submitOptions(); @@ -102,8 +97,13 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle ? `${totalVoters} ${pluralize(totalVoters, 'voter', 'voters')}` : `No voters${closed ? '' : ' yet'}`; }; - const updateVoteButtonText = (isAnswered: boolean) => { - voteButton.textContent = isAnswered ? 'View Results' : 'Vote'; + + const updateVoteButtonText = (voted: boolean) => { + getInterface(voteFooterEl).updateState(voted ? VoteButtonState.ShowResults : VoteButtonState.Vote); + }; + + const updateVoters = (voters: number) => { + getInterface(voteFooterEl).updateVoters(voters); }; const updatePollResults = (updatedPoll: Poll | undefined, updatedResults: PollResults) => { @@ -120,6 +120,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const updateMaxVoters = Math.max(...updatedResults.results.map((r) => r.voters)); const updateAnswered = updatedResults.results.findIndex((r) => r.chosen) >= 0; updateVoteButtonText(updateAnswered); + updateVoters(updateTotalVoters); updatedResults.results.forEach((r) => { const op = options.get(decoder.decode(r.option)); if (op) { @@ -137,13 +138,19 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle updateTotalVotersText(pollData.closed ?? false, results.total_voters ?? 0); updateVoteButtonText(answered); + updateVoters(results.total_voters ?? 0); + info.classList.add('poll__message-info'); const container = div`.poll`( - div`.poll__question`(text(pollData.question)), - div`poll__info`(div`poll__type`(pollTypeText), recentVoters), - div`.poll__options`(...pollOptions), - pollData.multiple_choice ? voteButton : span`.poll__voters`(totalVotersText), - info, + div`.poll__body`( + div`.poll__question`(text(pollData.question)), + div`poll__info`(div`poll__type`(pollTypeText), recentVoters), + div`.poll__options`(...pollOptions), + ), + div`.poll__footer`( + voteFooterEl, + info, + ), ); useWhileMounted(container, () => messageCache.watchItem(peerMessageToId(peer, message.id), (item) => { diff --git a/src/components/media/poll/poll_option.ts b/src/components/media/poll/poll_option.ts index ef22c151..c6202723 100644 --- a/src/components/media/poll/poll_option.ts +++ b/src/components/media/poll/poll_option.ts @@ -1,6 +1,6 @@ import { useInterface, getInterface } from 'core/hooks'; import { PollAnswer, PollAnswerVoters } from 'mtproto-js'; -import { text, span, div } from 'core/html'; +import { text, span, div, label } from 'core/html'; import { svgEl, unmountChildren, mount } from 'core/dom'; import { close as closeIcon, check as checkIcon } from 'components/icons'; import pollCheckbox from './poll_checkbox'; @@ -48,7 +48,7 @@ export default function pollOption(initialProps: Props) { const line = svgEl('svg', { width: 300, height: 30, class: 'pollOption__line' }, [ path = svgEl('path', { d: 'M20 8 v 3.5 a 13 13 0 0 0 13 13 H 300' }), ]); - const container = div`.pollOption`( + const container = label`.pollOption`( line, percentage, checkbox, diff --git a/src/components/media/poll/vote_footer.ts b/src/components/media/poll/vote_footer.ts new file mode 100644 index 00000000..169a6014 --- /dev/null +++ b/src/components/media/poll/vote_footer.ts @@ -0,0 +1,71 @@ +import { text, div } from 'core/html'; +import { ripple } from 'components/ui'; +import { useInterface } from 'core/hooks'; +import { pluralize } from 'helpers/other'; + +export enum VoteButtonState { + Vote, + ShowResults, +} + +const voteText = 'Vote'; +const viewResultsText = 'View Results'; + +function pluralizeVoters(voters: number, single: string, plural: string) { + return voters > 0 + ? `${voters} ${pluralize(voters, single, plural)}` + : `No ${plural}`; +} + +function formatStateText(state: VoteButtonState, voters: number, quiz: boolean, publicVoters: boolean, multipleChoice: boolean) { + if (!multipleChoice && !quiz) { + return pluralizeVoters(voters, 'vote', 'votes'); + } + if (quiz) { + if (state === VoteButtonState.ShowResults && publicVoters) { + return viewResultsText; + } + return pluralizeVoters(voters, 'answer', 'answers'); + } + + // Only multiple choice vote case left. + if (state === VoteButtonState.ShowResults) { + if (publicVoters) { + return viewResultsText; + } + return pluralizeVoters(voters, 'vote', 'votes'); + } + + return voteText; +} + +export default function voteFooter(quiz: boolean, publicVoters: boolean, multipleChoice: boolean, onSubmit: () => void, onShowResults: () => void) { + let state = VoteButtonState.Vote; + let voters = 0; + const stateText = text(''); + const container: HTMLElement = ripple({}, [ + div`.poll__vote-button.-inactive`( + { + onClick: async () => { + if (state === VoteButtonState.ShowResults) { + onShowResults(); + } else { + onSubmit(); + } + }, + }, + stateText, + ), + ]); + + return useInterface(container, { + updateState: (newState: VoteButtonState) => { + state = newState; + stateText.textContent = formatStateText(state, voters, quiz, publicVoters, multipleChoice); + }, + updateVoters: (newVoters: number) => { + voters = newVoters; + stateText.textContent = formatStateText(state, voters, quiz, publicVoters, multipleChoice); + }, + }); +} diff --git a/src/components/message/message.scss b/src/components/message/message.scss index 1e0075f4..e7133c65 100644 --- a/src/components/message/message.scss +++ b/src/components/message/message.scss @@ -89,10 +89,9 @@ $messageDistance: 4PX; &.with-webpage { max-width: 440px; } &.with-webpage-media { width: 340px; } &.with-photo { width: 320px; } + &.with-poll { width: 320px; } - &.only-photo { - max-width: 320px; - } + &.only-photo { max-width: 320px; } } &__info { diff --git a/src/components/message/message.ts b/src/components/message/message.ts index 4310c50c..b24474ee 100644 --- a/src/components/message/message.ts +++ b/src/components/message/message.ts @@ -207,14 +207,12 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: // with poll if (msg.media._ === 'messageMediaPoll') { - const extraClass = hasMessage ? 'with-poll' : 'only-poll'; + const extraClass = 'with-poll'; return { message: bubble( { out, className: extraClass }, reply, - div`.message__text`( - poll(peer, msg, info), - ), + poll(peer, msg, info), ), info, }; From 55af64399acc7d39a9239166ede9ee474c36783e Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sat, 16 May 2020 18:44:53 +0300 Subject: [PATCH 21/26] wip --- src/components/media/poll/poll.ts | 21 +++++++++-------- .../poll/{vote_footer.ts => poll_footer.ts} | 23 ++++++++++++++----- src/components/popup/popup.ts | 2 +- src/components/ui/ripple/ripple.ts | 7 ++++++ 4 files changed, 36 insertions(+), 17 deletions(-) rename src/components/media/poll/{vote_footer.ts => poll_footer.ts} (75%) diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 53974d62..55204ed8 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -1,5 +1,5 @@ import { Poll, PollResults, PollAnswerVoters, Peer, Message } from 'mtproto-js'; -import { div, text, span } from 'core/html'; +import { div, text } from 'core/html'; import { mount, unmountChildren } from 'core/dom'; import { useWhileMounted, getInterface } from 'core/hooks'; import { polls, main } from 'services'; @@ -8,7 +8,7 @@ import { peerMessageToId, userIdToPeer } from 'helpers/api'; import { profileAvatar } from 'components/profile'; import { pluralize } from 'helpers/other'; import pollOption, { PollOptionInterface } from './poll_option'; -import voteFooter, { VoteButtonState } from './vote_footer'; +import pollFooter, { VoteButtonState } from './poll_footer'; import './poll.scss'; @@ -36,7 +36,8 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle if (message.media?._ !== 'messageMediaPoll') { throw new Error('message media must be of type "messageMediaPoll"'); } - const { poll: pollData, results } = message.media; + const { results } = message.media; + const pollData = message.media.poll as Required; const selectedOptions = new Set(); const pollOptions: ReturnType[] = []; const totalVotersText = text(''); @@ -52,10 +53,10 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const options = new Map(); const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; - const voteFooterEl = voteFooter( - pollData.quiz ?? false, - pollData.public_voters ?? false, - pollData.multiple_choice ?? false, + const voteFooterEl = pollFooter( + pollData.quiz, + pollData.public_voters, + pollData.multiple_choice, () => { submitOptions(); }, () => { main.showPopup('pollResults', { peer, message, poll: pollData }); }); pollData.answers.forEach((answer) => { @@ -65,11 +66,11 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle voters = results.results.find((r) => decoder.decode(r.option) === optionKey); } const option = pollOption({ - quiz: pollData.quiz ?? false, - multipleChoice: pollData.multiple_choice ?? false, + quiz: pollData.quiz, + multipleChoice: pollData.multiple_choice, option: answer, answered, - closed: pollData.closed ?? false, + closed: pollData.closed, voters, maxVoters, totalVoters: results.total_voters ?? 0, diff --git a/src/components/media/poll/vote_footer.ts b/src/components/media/poll/poll_footer.ts similarity index 75% rename from src/components/media/poll/vote_footer.ts rename to src/components/media/poll/poll_footer.ts index 169a6014..f169790e 100644 --- a/src/components/media/poll/vote_footer.ts +++ b/src/components/media/poll/poll_footer.ts @@ -1,6 +1,6 @@ import { text, div } from 'core/html'; import { ripple } from 'components/ui'; -import { useInterface } from 'core/hooks'; +import { useInterface, getInterface } from 'core/hooks'; import { pluralize } from 'helpers/other'; export enum VoteButtonState { @@ -39,14 +39,17 @@ function formatStateText(state: VoteButtonState, voters: number, quiz: boolean, return voteText; } -export default function voteFooter(quiz: boolean, publicVoters: boolean, multipleChoice: boolean, onSubmit: () => void, onShowResults: () => void) { +export default function pollFooter(quiz: boolean, publicVoters: boolean, multipleChoice: boolean, onSubmit: () => void, onShowResults: () => void) { let state = VoteButtonState.Vote; let voters = 0; const stateText = text(''); - const container: HTMLElement = ripple({}, [ + const container = ripple({}, [ div`.poll__vote-button.-inactive`( { onClick: async () => { + if (!publicVoters) { + return; + } if (state === VoteButtonState.ShowResults) { onShowResults(); } else { @@ -58,14 +61,22 @@ export default function voteFooter(quiz: boolean, publicVoters: boolean, multipl ), ]); - return useInterface(container, { + const updateState = () => { + stateText.textContent = formatStateText(state, voters, quiz, publicVoters, multipleChoice); + getInterface(container).setEnabled(false); + }; + + updateState(); + + return useInterface(container as HTMLElement, { + ...getInterface(container), updateState: (newState: VoteButtonState) => { state = newState; - stateText.textContent = formatStateText(state, voters, quiz, publicVoters, multipleChoice); + updateState(); }, updateVoters: (newVoters: number) => { voters = newVoters; - stateText.textContent = formatStateText(state, voters, quiz, publicVoters, multipleChoice); + updateState(); }, }); } diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts index 1ae587a1..674da6a9 100644 --- a/src/components/popup/popup.ts +++ b/src/components/popup/popup.ts @@ -18,7 +18,7 @@ export default function popup() { useObservable(wrapper, main.popup, (type: string) => { if (element) unmount(element); - if (wrapper.classList.contains('closing')) wrapper.classList.remove('closing'); + wrapper.classList.remove('closing'); switch (type) { case '': diff --git a/src/components/ui/ripple/ripple.ts b/src/components/ui/ripple/ripple.ts index 202c8587..6f901452 100644 --- a/src/components/ui/ripple/ripple.ts +++ b/src/components/ui/ripple/ripple.ts @@ -16,8 +16,12 @@ interface Props extends Record { export default function ripple({ tag = 'div', className = '', contentClass = '', onClick, ...props }: Props, children: Node[] = []) { const contentEl = div`.ripple__content ${contentClass}`(...children); const element = el(tag, { className: `ripple ${className}`, ...props }, [contentEl]); + let enabled = true; listen(element, 'click', (event) => { + if (!enabled) { + return; + } const rect = element.getBoundingClientRect(); const effect = div`.ripple__effect`({ style: { @@ -42,5 +46,8 @@ export default function ripple({ tag = 'div', className = '', contentClass = '', mountChild(...moreChildren: Node[]) { moreChildren.forEach((child) => mount(contentEl, child)); }, + setEnabled: (value: boolean) => { + enabled = value; + }, }); } From 0a7cecee6cc36ecf956e613934a6937dd9704a69 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sat, 23 May 2020 17:47:28 +0300 Subject: [PATCH 22/26] wip --- src/cache/fastStorages/indices/pollsIndex.ts | 58 ++++++++++--------- src/components/media/poll/poll.scss | 1 - src/components/media/poll/poll.ts | 56 ++++++++++-------- src/components/media/poll/poll_checkbox.ts | 5 +- src/components/media/poll/poll_footer.scss | 18 ++++++ src/components/media/poll/poll_footer.ts | 46 +++++++++------ src/components/media/poll/poll_option.ts | 9 ++- .../poll_results/poll_result_option.scss | 39 ++++++++++++- .../popup/poll_results/poll_result_option.ts | 41 ++++++++++--- .../popup/poll_results/poll_results.scss | 3 +- .../popup/poll_results/poll_results.ts | 21 +++---- src/services/main.ts | 6 +- 12 files changed, 202 insertions(+), 101 deletions(-) create mode 100644 src/components/media/poll/poll_footer.scss diff --git a/src/cache/fastStorages/indices/pollsIndex.ts b/src/cache/fastStorages/indices/pollsIndex.ts index 96df1354..fd1a47a3 100644 --- a/src/cache/fastStorages/indices/pollsIndex.ts +++ b/src/cache/fastStorages/indices/pollsIndex.ts @@ -1,7 +1,9 @@ -import { Message, Update, PollResults } from 'mtproto-js'; import { messageToId } from 'helpers/api'; +import { Message, PollResults, Update } from 'mtproto-js'; import Collection from '../collection'; +const decoder = new TextDecoder(); + export default function pollsIndex(collection: Collection) { const pollMessageIds = new Map>(); collection.changes.subscribe((collectionChanges) => { @@ -11,17 +13,20 @@ export default function pollsIndex(collection: Collection) { } switch (action) { case 'add': { - let messages = pollMessageIds.get(item.media.poll.id); - if (!messages) { - pollMessageIds.set(item.media.poll.id, messages = new Set()); + let messageIds = pollMessageIds.get(item.media.poll.id); + if (!messageIds) { + pollMessageIds.set(item.media.poll.id, messageIds = new Set()); } - messages.add(messageToId(item)); + messageIds.add(messageToId(item)); break; } case 'remove': { - const messages = pollMessageIds.get(item.media.poll.id); - if (messages) { - messages.delete(messageToId(item)); + const messageIds = pollMessageIds.get(item.media.poll.id); + if (messageIds) { + messageIds.delete(messageToId(item)); + if (messageIds.size === 0) { + pollMessageIds.delete(item.media.poll.id); + } } break; } @@ -31,26 +36,24 @@ export default function pollsIndex(collection: Collection) { ); }); - const expandMinUpdateResults = (prevResults: PollResults, newResults: PollResults) => { - if (!newResults.min) { - return newResults; + const applyMinPollResults = (prevPollResults: PollResults, newPollResults: PollResults) => { + if (!newPollResults.min) { + return newPollResults; } - const result: PollResults = { ...prevResults, ...newResults }; - if (prevResults.results && newResults.results) { - const results = result.results = [...newResults.results.map((r) => ({ ...r }))]; - const decoder = new TextDecoder(); - const prevResultOptions = new Map(prevResults.results.map((r) => [decoder.decode(r.option), r])); - results.forEach((option) => { - const prevOptions = prevResultOptions.get(decoder.decode(option.option)); - if (prevOptions) { - // eslint-disable-next-line no-param-reassign - option.correct = prevOptions.correct; - // eslint-disable-next-line no-param-reassign - option.chosen = prevOptions.chosen; + const pollResults: PollResults = { ...prevPollResults, ...newPollResults }; + if (prevPollResults.results && newPollResults.results) { + const results = pollResults.results = newPollResults.results.map((r) => ({ ...r })); + const prevResultOptions = new Map(prevPollResults.results.map((r) => [decoder.decode(r.option), r])); + for (let index = 0; index < results.length; index++) { + const result = results[index]; + const prevResults = prevResultOptions.get(decoder.decode(result.option)); + if (prevResults) { + result.correct = prevResults.correct; + result.chosen = prevResults.chosen; } - }); + } } - return result; + return pollResults; }; return { @@ -60,15 +63,16 @@ export default function pollsIndex(collection: Collection) { messageIds.forEach((messageId) => { const message = collection.get(messageId); if (message?._ === 'message' && message.media?._ === 'messageMediaPoll') { - const expandedResults = expandMinUpdateResults(message.media.results, update.results); + const updatedResults = applyMinPollResults(message.media.results, update.results); const updatedMedia = { ...message.media, - results: expandedResults, + results: updatedResults, }; if (update.poll) { updatedMedia.poll = update.poll; } const updatedMessage = { + ...message, media: updatedMedia, }; collection.change(messageId, updatedMessage); diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index 01e78e60..cf7e908c 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -68,7 +68,6 @@ & &__vote-button { margin: 8PX 0; - text-transform: uppercase; text-align: center; color: var(--accent-color); transition: color 0.3s; diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 55204ed8..8800e0a0 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -1,16 +1,16 @@ -import { Poll, PollResults, PollAnswerVoters, Peer, Message } from 'mtproto-js'; -import { div, text } from 'core/html'; -import { mount, unmountChildren } from 'core/dom'; -import { useWhileMounted, getInterface } from 'core/hooks'; -import { polls, main } from 'services'; import { messageCache } from 'cache'; -import { peerMessageToId, userIdToPeer } from 'helpers/api'; import { profileAvatar } from 'components/profile'; +import { mount, unmountChildren } from 'core/dom'; +import { getInterface, useWhileMounted } from 'core/hooks'; +import { div, text } from 'core/html'; +import { peerMessageToId, userIdToPeer } from 'helpers/api'; import { pluralize } from 'helpers/other'; -import pollOption, { PollOptionInterface } from './poll_option'; +import { Message, Peer, Poll, PollAnswerVoters, PollResults } from 'mtproto-js'; +import { main, polls } from 'services'; +import './poll.scss'; import pollFooter, { VoteButtonState } from './poll_footer'; +import pollOption, { PollOptionInterface } from './poll_option'; -import './poll.scss'; const decoder = new TextDecoder(); const encoder = new TextEncoder(); @@ -36,8 +36,9 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle if (message.media?._ !== 'messageMediaPoll') { throw new Error('message media must be of type "messageMediaPoll"'); } - const { results } = message.media; - const pollData = message.media.poll as Required; + const { media } = message; + const { results } = media; + const pollData = media.poll as Required; const selectedOptions = new Set(); const pollOptions: ReturnType[] = []; const totalVotersText = text(''); @@ -53,12 +54,15 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const options = new Map(); const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; - const voteFooterEl = pollFooter( - pollData.quiz, - pollData.public_voters, - pollData.multiple_choice, - () => { submitOptions(); }, - () => { main.showPopup('pollResults', { peer, message, poll: pollData }); }); + + const voteFooterEl = pollFooter({ + quiz: pollData.quiz, + publicVoters: pollData.public_voters, + multipleChoice: pollData.multiple_choice, + onSubmit: () => { submitOptions(); }, + onShowResults: () => { main.showPopup('pollResults', { peer, message, poll: media }); }, + }); + pollData.answers.forEach((answer) => { const optionKey = decoder.decode(answer.option); let voters: PollAnswerVoters | undefined; @@ -99,17 +103,21 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle : `No voters${closed ? '' : ' yet'}`; }; - const updateVoteButtonText = (voted: boolean) => { - getInterface(voteFooterEl).updateState(voted ? VoteButtonState.ShowResults : VoteButtonState.Vote); + const updateVoteButtonText = (closed: boolean, voted: boolean, publicVoters: boolean) => { + if (voted && !publicVoters) { + getInterface(voteFooterEl).updateState(VoteButtonState.Inactive); + } else { + getInterface(voteFooterEl).updateState(closed || voted ? VoteButtonState.ShowResults : VoteButtonState.Vote); + } }; const updateVoters = (voters: number) => { getInterface(voteFooterEl).updateVoters(voters); }; - const updatePollResults = (updatedPoll: Poll | undefined, updatedResults: PollResults) => { + const updatePollResults = (updatedPoll: Required, updatedResults: PollResults) => { const updateTotalVoters = updatedResults.total_voters ?? 0; - updateTotalVotersText(updatedPoll?.closed ?? false, updateTotalVoters); + updateTotalVotersText(updatedPoll.closed, updateTotalVoters); if (updatedPoll) { pollTypeText.textContent = pollType(updatedPoll); } @@ -120,7 +128,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle }); const updateMaxVoters = Math.max(...updatedResults.results.map((r) => r.voters)); const updateAnswered = updatedResults.results.findIndex((r) => r.chosen) >= 0; - updateVoteButtonText(updateAnswered); + updateVoteButtonText(updatedPoll.closed, updateAnswered, updatedPoll.public_voters); updateVoters(updateTotalVoters); updatedResults.results.forEach((r) => { const op = options.get(decoder.decode(r.option)); @@ -137,8 +145,8 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle } }; - updateTotalVotersText(pollData.closed ?? false, results.total_voters ?? 0); - updateVoteButtonText(answered); + updateTotalVotersText(pollData.closed, results.total_voters ?? 0); + updateVoteButtonText(pollData.closed, answered, pollData.public_voters); updateVoters(results.total_voters ?? 0); info.classList.add('poll__message-info'); @@ -156,7 +164,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle useWhileMounted(container, () => messageCache.watchItem(peerMessageToId(peer, message.id), (item) => { if (item?._ === 'message' && item.media?._ === 'messageMediaPoll') { - updatePollResults(item.media.poll, item.media.results); + updatePollResults(item.media.poll as Required, item.media.results); } })); diff --git a/src/components/media/poll/poll_checkbox.ts b/src/components/media/poll/poll_checkbox.ts index e0713a9e..a7ff84e1 100644 --- a/src/components/media/poll/poll_checkbox.ts +++ b/src/components/media/poll/poll_checkbox.ts @@ -1,11 +1,10 @@ import { checkbox } from 'components/ui'; -import { div } from 'core/html'; import { mount, unmount } from 'core/dom'; import { svgCodeToComponent } from 'core/factory'; import { getInterface, useInterface } from 'core/hooks'; -import spinnerCode from './spinner.svg?raw'; - +import { div } from 'core/html'; import './poll_checkbox.scss'; +import spinnerCode from './spinner.svg?raw'; const spinnerSvg = svgCodeToComponent(spinnerCode); diff --git a/src/components/media/poll/poll_footer.scss b/src/components/media/poll/poll_footer.scss new file mode 100644 index 00000000..59aa1938 --- /dev/null +++ b/src/components/media/poll/poll_footer.scss @@ -0,0 +1,18 @@ +.pollFooter { + display: grid; + margin: 8PX 0; + text-align: center; + color: var(--accent-color); + transition: color 0.3s; + cursor: pointer; + + >* { + grid-row: 1; + grid-column: 1; + } +} + +.pollFooter.-inactive { + color: var(--accent-color-inactive); + cursor: default; +} diff --git a/src/components/media/poll/poll_footer.ts b/src/components/media/poll/poll_footer.ts index f169790e..a61a2a56 100644 --- a/src/components/media/poll/poll_footer.ts +++ b/src/components/media/poll/poll_footer.ts @@ -1,31 +1,38 @@ -import { text, div } from 'core/html'; import { ripple } from 'components/ui'; -import { useInterface, getInterface } from 'core/hooks'; +import { getInterface, useInterface } from 'core/hooks'; +import { div, text } from 'core/html'; import { pluralize } from 'helpers/other'; +import './poll_footer.scss'; export enum VoteButtonState { + Inactive, Vote, ShowResults, } -const voteText = 'Vote'; -const viewResultsText = 'View Results'; +const voteText = 'VOTE'; +const viewResultsText = 'VIEW RESULTS'; -function pluralizeVoters(voters: number, single: string, plural: string) { +function pluralizeVoters(quiz: boolean, voters: number) { + if (quiz) { + return voters > 0 + ? `${voters} ${pluralize(voters, 'answer', 'answers')}` + : 'No answers'; + } return voters > 0 - ? `${voters} ${pluralize(voters, single, plural)}` - : `No ${plural}`; + ? `${voters} ${pluralize(voters, 'vote', 'votess')}` + : 'No votes'; } function formatStateText(state: VoteButtonState, voters: number, quiz: boolean, publicVoters: boolean, multipleChoice: boolean) { - if (!multipleChoice && !quiz) { - return pluralizeVoters(voters, 'vote', 'votes'); + if (state === VoteButtonState.Inactive || (!multipleChoice && !quiz)) { + return pluralizeVoters(quiz, voters); } if (quiz) { if (state === VoteButtonState.ShowResults && publicVoters) { return viewResultsText; } - return pluralizeVoters(voters, 'answer', 'answers'); + return pluralizeVoters(quiz, voters); } // Only multiple choice vote case left. @@ -33,23 +40,28 @@ function formatStateText(state: VoteButtonState, voters: number, quiz: boolean, if (publicVoters) { return viewResultsText; } - return pluralizeVoters(voters, 'vote', 'votes'); + return pluralizeVoters(quiz, voters); } return voteText; } -export default function pollFooter(quiz: boolean, publicVoters: boolean, multipleChoice: boolean, onSubmit: () => void, onShowResults: () => void) { +type Props = { + quiz: boolean, + publicVoters: boolean, + multipleChoice: boolean, + onSubmit: () => void, + onShowResults: () => void, +}; + +export default function pollFooter({ quiz, publicVoters, multipleChoice, onSubmit, onShowResults }: Props) { let state = VoteButtonState.Vote; let voters = 0; const stateText = text(''); const container = ripple({}, [ - div`.poll__vote-button.-inactive`( + div`.pollFooter.-inactive`( { onClick: async () => { - if (!publicVoters) { - return; - } if (state === VoteButtonState.ShowResults) { onShowResults(); } else { @@ -63,7 +75,7 @@ export default function pollFooter(quiz: boolean, publicVoters: boolean, multipl const updateState = () => { stateText.textContent = formatStateText(state, voters, quiz, publicVoters, multipleChoice); - getInterface(container).setEnabled(false); + getInterface(container).setEnabled(voters > 0 && state !== VoteButtonState.Inactive); }; updateState(); diff --git a/src/components/media/poll/poll_option.ts b/src/components/media/poll/poll_option.ts index c6202723..19ff5415 100644 --- a/src/components/media/poll/poll_option.ts +++ b/src/components/media/poll/poll_option.ts @@ -1,10 +1,9 @@ -import { useInterface, getInterface } from 'core/hooks'; +import { check as checkIcon, close as closeIcon } from 'components/icons'; +import { mount, svgEl, unmountChildren } from 'core/dom'; +import { getInterface, useInterface } from 'core/hooks'; +import { div, label, span, text } from 'core/html'; import { PollAnswer, PollAnswerVoters } from 'mtproto-js'; -import { text, span, div, label } from 'core/html'; -import { svgEl, unmountChildren, mount } from 'core/dom'; -import { close as closeIcon, check as checkIcon } from 'components/icons'; import pollCheckbox from './poll_checkbox'; - import './poll_option.scss'; type Props = { diff --git a/src/components/popup/poll_results/poll_result_option.scss b/src/components/popup/poll_results/poll_result_option.scss index 91895bba..5d273e2d 100644 --- a/src/components/popup/poll_results/poll_result_option.scss +++ b/src/components/popup/poll_results/poll_result_option.scss @@ -6,6 +6,10 @@ margin-top: 8px; padding: 8px; + &__text { + padding-right: 16px; + } + &__header { display: grid; grid-template-columns: 1fr auto; @@ -21,13 +25,42 @@ align-items: center; margin-top: 10px; - .avatar { - max-width: 25px; - max-height: 25px; + & .avatar { + max-width: 30px; + max-height: 30px; } } + &__voter.-hidden { + display: none; + } + &__voter-name { margin-left: 10px; } + + &__voter-placeholder { + display: flex; + align-items: center; + margin-top: 10px; + + & .avatar { + width: 30px; + height: 30px; + border-radius: 100%; + background-color: rgba(0, 0, 0, 0.08); + } + + & .voter-name { + width: 30px; + height: 20px; + margin-left: 10px; + border-radius: 8px; + background-color: rgba(0, 0, 0, 0.08); + } + } +} + +.pollResultOption.-hidden { + display: none; } diff --git a/src/components/popup/poll_results/poll_result_option.ts b/src/components/popup/poll_results/poll_result_option.ts index 29b2f108..f07d36a6 100644 --- a/src/components/popup/poll_results/poll_result_option.ts +++ b/src/components/popup/poll_results/poll_result_option.ts @@ -1,25 +1,52 @@ -import { div, text } from 'core/html'; +import { profileAvatar, profileTitle } from 'components/profile'; +import { mount, unmount, unmountChildren } from 'core/dom'; import { useInterface } from 'core/hooks'; -import { pluralize } from 'helpers/other'; +import { div, text } from 'core/html'; import { userIdToPeer } from 'helpers/api'; -import { mount } from 'core/dom'; -import { profileAvatar, profileTitle } from 'components/profile'; - +import { pluralize } from 'helpers/other'; import './poll_result_option.scss'; export default function pollResultOption(option: ArrayBuffer, optionText: string, quiz: boolean) { const optionTextEl = div`.pollResultOption__text`(text(optionText)); const votersCountEl = text(''); const votersListEl = div`.pollResultOption__voters-list`(); + const voterPlaceholders: Element[] = []; + const voterIdToElementMap = new Map(); const container = div`.pollResultOption`(div`.pollResultOption__header`(optionTextEl, votersCountEl), votersListEl); return useInterface(container, { setVoters: (voters: number, totalVoters: number) => { + if (voters === 0) { + container.classList.add('-hidden'); + } optionTextEl.textContent = `${optionText} \u2014 ${Math.round((voters / totalVoters) * 100)}%`; votersCountEl.textContent = `${voters} ${quiz ? pluralize(voters, 'answer', 'answers') : pluralize(voters, 'vote', 'votes')}`; + unmountChildren(votersListEl); + for (let i = 0; i < voters; i++) { + const titleWidth = 100 + Math.round(Math.random() * 100); + const placeholder = div`.pollResultOption__voter-placeholder`(div`.avatar`(), div`.voter-name`({ style: { width: `${titleWidth}px` } })); + voterPlaceholders.push(placeholder); + mount(votersListEl, placeholder); + } }, setVoter: (userId: number, voted: boolean) => { - const peer = userIdToPeer(userId); - mount(votersListEl, div`.pollResultOption__voter`(profileAvatar(userIdToPeer(userId)), div`.pollResultOption__voter-name`(profileTitle(peer)))); + let voterEl = voterIdToElementMap.get(userId); + if (!voterEl) { + const placeholder = voterPlaceholders.pop(); + if (placeholder) { + unmount(placeholder); + } + const peer = userIdToPeer(userId); + voterEl = div`.pollResultOption__voter`( + profileAvatar(userIdToPeer(userId)), + div`.pollResultOption__voter-name`( + profileTitle(peer), + ), + ); + voterIdToElementMap.set(userId, voterEl); + mount(votersListEl, voterEl); + } else { + voterEl.classList.toggle('-hidden', !voted); + } }, }); } diff --git a/src/components/popup/poll_results/poll_results.scss b/src/components/popup/poll_results/poll_results.scss index 71edb595..47e513f3 100644 --- a/src/components/popup/poll_results/poll_results.scss +++ b/src/components/popup/poll_results/poll_results.scss @@ -1,5 +1,6 @@ .pollResultsPopup { - width: 400px; + min-width: 400px; + max-width: 50vw; display: flex; flex-direction: column; max-height: 80vh; diff --git a/src/components/popup/poll_results/poll_results.ts b/src/components/popup/poll_results/poll_results.ts index 502a65fd..7934f8dc 100644 --- a/src/components/popup/poll_results/poll_results.ts +++ b/src/components/popup/poll_results/poll_results.ts @@ -1,26 +1,27 @@ -import { Poll, Peer, Message, MessageUserVote } from 'mtproto-js'; +import { messageCache, userCache } from 'cache'; import { peerToInputPeer } from 'cache/accessors'; -import { div, text } from 'core/html'; +import client from 'client/client'; import { listen } from 'core/dom'; import { getInterface } from 'core/hooks'; -import client from 'client/client'; -import { userCache, messageCache } from 'cache'; +import { div, text } from 'core/html'; import { peerMessageToId } from 'helpers/api'; +import { Message, MessageUserVote, Peer } from 'mtproto-js'; import popupCommon from '../popup_common'; -import pollResultOption from './poll_result_option'; - import './poll_results.scss'; +import pollResultOption from './poll_result_option'; export type PollResultsContext = { peer: Peer, message: Message.message, - poll: Poll, }; const decoder = new TextDecoder(); -export default function pollResultsPopup({ peer, message, poll }: PollResultsContext) { - // const loader = materialSpinner({ className: 'pollResultsPopup__loading' }); +export default function pollResultsPopup({ peer, message }: PollResultsContext) { + if (message.media?._ !== 'messageMediaPoll') { + throw new Error('message media must be of type "messageMediaPoll"'); + } + const { poll } = message.media; const close = div`.popup__close`(); const options = new Map(poll.answers.map((answer) => [ decoder.decode(answer.option), @@ -29,7 +30,6 @@ export default function pollResultsPopup({ peer, message, poll }: PollResultsCon const content = div`.popup__content.pollResultsPopup`( div`pollResultsPopup__question`(text(poll.question)), div`pollResultsPopup__options`(...options.values()), - // loader, ); const container = popupCommon( div`.popup__header`( @@ -81,6 +81,7 @@ export default function pollResultsPopup({ peer, message, poll }: PollResultsCon }); }; + // todo: fetch all votes. Now we only get first 50 voters per answer. async function loadData() { for (let index = 0; index < poll.answers.length; index++) { const answer = poll.answers[index]; diff --git a/src/services/main.ts b/src/services/main.ts index d31702fe..2aea3311 100644 --- a/src/services/main.ts +++ b/src/services/main.ts @@ -1,7 +1,7 @@ -import { BehaviorSubject } from 'rxjs'; import client from 'client/client'; -import { Photo, Message, Peer, InputStickerSet, Document, Poll } from 'mtproto-js'; import { PhotoOptions } from 'helpers/other'; +import { Document, InputStickerSet, Message, MessageMedia, Peer, Photo } from 'mtproto-js'; +import { BehaviorSubject } from 'rxjs'; type SidebarState = import('components/sidebar/sidebar').SidebarState; @@ -31,7 +31,7 @@ export default class MainService { showPopup(type: 'stickerSet', ctx: InputStickerSet): void; showPopup(type: 'photo', ctx: { rect: DOMRect, options: PhotoOptions, photo: Photo, peer: Peer, message: Message }): void; showPopup(type: 'video', ctx: { rect: DOMRect, video: Document.document, peer?: Peer, message?: Message }): void; - showPopup(type: 'pollResults', ctx: { peer: Peer, message: Message.message, poll: Poll }): void; + showPopup(type: 'pollResults', ctx: { peer: Peer, message: Message.message, poll: MessageMedia.messageMediaPoll }): void; showPopup(type: string, ctx?: any): void { this.popupCtx = ctx; this.popup.next(type); From b9b4aef1d08d1f2108d3865733e349392f196ed4 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sun, 24 May 2020 23:52:55 +0300 Subject: [PATCH 23/26] wip --- src/components/media/document/file.ts | 14 +-- src/components/media/poll/poll.ts | 85 +++++++++--------- src/components/media/poll/poll_footer.scss | 2 +- src/components/media/poll/poll_footer.ts | 86 ++++++++++--------- src/components/message/message.scss | 3 +- src/components/message/service.scss | 2 +- .../popup/poll_results/poll_results.ts | 13 +-- src/helpers/data.ts | 5 ++ src/services/main.ts | 4 +- src/services/polls.ts | 48 ++++++++++- 10 files changed, 157 insertions(+), 105 deletions(-) diff --git a/src/components/media/document/file.ts b/src/components/media/document/file.ts index 158d6bf2..447d40be 100644 --- a/src/components/media/document/file.ts +++ b/src/components/media/document/file.ts @@ -1,11 +1,11 @@ -import { Document, Message } from 'mtproto-js'; +import { cached as getCached, file } from 'client/media'; +import { materialSpinner } from 'components/icons'; +import { datetime } from 'components/ui'; +import { listen, mount, unmountChildren } from 'core/dom'; import { div, text } from 'core/html'; -import { getAttributeFilename, getReadableSize, getDocumentLocation } from 'helpers/files'; +import { getAttributeFilename, getDocumentLocation, getReadableSize } from 'helpers/files'; import { downloadByUrl } from 'helpers/other'; -import { datetime } from 'components/ui'; -import { listen, mount, unmountChildren, unmount } from 'core/dom'; -import { materialSpinner, down } from 'components/icons'; -import { download, cached as getCached, file } from 'client/media'; +import { Document, Message } from 'mtproto-js'; import photoRenderer from '../photo/photo'; import './file.scss'; @@ -64,7 +64,7 @@ export default function documentFile(document: Document.document, message?: Mess sizeEl.textContent = '0%'; downloadByUrl(filename, file(getDocumentLocation(document, ''), { dc_id: document.dc_id, size: document.size, mime_type: document.mime_type })); - + // download( // getDocumentLocation(document, ''), // { dc_id: document.dc_id, size: document.size, mime_type: document.mime_type }, diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 8800e0a0..5b5d8abd 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -4,7 +4,6 @@ import { mount, unmountChildren } from 'core/dom'; import { getInterface, useWhileMounted } from 'core/hooks'; import { div, text } from 'core/html'; import { peerMessageToId, userIdToPeer } from 'helpers/api'; -import { pluralize } from 'helpers/other'; import { Message, Peer, Poll, PollAnswerVoters, PollResults } from 'mtproto-js'; import { main, polls } from 'services'; import './poll.scss'; @@ -41,26 +40,32 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const pollData = media.poll as Required; const selectedOptions = new Set(); const pollOptions: ReturnType[] = []; - const totalVotersText = text(''); const pollTypeText = text(pollType(pollData)); const recentVoters = div`poll__recent-voters`(...buildRecentVotersList(results.recent_voters)); + const options = new Map(); + let answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; + const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; + const submitOptions = async () => { - const optionsArray: ArrayBuffer[] = []; - selectedOptions.forEach((o) => optionsArray.push(encoder.encode(o).buffer)); - await polls.sendVote(peer, message.id, optionsArray); + if (!answered) { + const optionsArray: ArrayBuffer[] = []; + selectedOptions.forEach((o) => optionsArray.push(encoder.encode(o).buffer)); + try { + await polls.sendVote(peer, message.id, optionsArray); + } catch (e) { + console.log(e); + } + } selectedOptions.clear(); pollOptions.forEach((po) => getInterface(po).reset()); }; - const options = new Map(); - const answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; - const maxVoters = results.results ? Math.max(...results.results.map((r) => r.voters)) : 0; const voteFooterEl = pollFooter({ quiz: pollData.quiz, publicVoters: pollData.public_voters, multipleChoice: pollData.multiple_choice, - onSubmit: () => { submitOptions(); }, - onShowResults: () => { main.showPopup('pollResults', { peer, message, poll: media }); }, + onSubmit: submitOptions, + onViewResults: () => { main.showPopup('pollResults', { peer, messageId: peerMessageToId(peer, message.id) }); }, }); pollData.answers.forEach((answer) => { @@ -86,7 +91,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle } else { selectedOptions.delete(optKey); } - voteFooterEl.classList.toggle('-inactive', selectedOptions.size === 0); + getInterface(voteFooterEl).updateState(answered ? VoteButtonState.ViewResults : VoteButtonState.Vote, selectedOptions.size > 0); } else { selectedOptions.add(decoder.decode(answer.option)); await submitOptions(); @@ -97,17 +102,11 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle pollOptions.push(option); }); - const updateTotalVotersText = (closed: boolean, totalVoters: number) => { - totalVotersText.textContent = totalVoters > 0 - ? `${totalVoters} ${pluralize(totalVoters, 'voter', 'voters')}` - : `No voters${closed ? '' : ' yet'}`; - }; - const updateVoteButtonText = (closed: boolean, voted: boolean, publicVoters: boolean) => { if (voted && !publicVoters) { - getInterface(voteFooterEl).updateState(VoteButtonState.Inactive); + getInterface(voteFooterEl).updateState(VoteButtonState.ViewResults, selectedOptions.size > 0); } else { - getInterface(voteFooterEl).updateState(closed || voted ? VoteButtonState.ShowResults : VoteButtonState.Vote); + getInterface(voteFooterEl).updateState(closed || voted ? VoteButtonState.ViewResults : VoteButtonState.Vote, selectedOptions.size > 0); } }; @@ -117,37 +116,33 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const updatePollResults = (updatedPoll: Required, updatedResults: PollResults) => { const updateTotalVoters = updatedResults.total_voters ?? 0; - updateTotalVotersText(updatedPoll.closed, updateTotalVoters); if (updatedPoll) { pollTypeText.textContent = pollType(updatedPoll); } - if (updatedResults.results) { - unmountChildren(recentVoters); - buildRecentVotersList(updatedResults.recent_voters).forEach((avatar) => { - mount(recentVoters, avatar); - }); - const updateMaxVoters = Math.max(...updatedResults.results.map((r) => r.voters)); - const updateAnswered = updatedResults.results.findIndex((r) => r.chosen) >= 0; - updateVoteButtonText(updatedPoll.closed, updateAnswered, updatedPoll.public_voters); - updateVoters(updateTotalVoters); - updatedResults.results.forEach((r) => { - const op = options.get(decoder.decode(r.option)); - if (op) { - getInterface(op).updateOption({ - voters: r, - answered: updateAnswered, - closed: updatedPoll?.closed, - maxVoters: updateMaxVoters, - totalVoters: updateTotalVoters, - }); - } - }); - } + const voters = updatedResults.results ?? []; + unmountChildren(recentVoters); + buildRecentVotersList(updatedResults.recent_voters).forEach((avatar) => { + mount(recentVoters, avatar); + }); + const updateMaxVoters = Math.max(...voters.map((r) => r.voters)); + answered = voters.findIndex((r) => r.chosen) >= 0; + updateVoteButtonText(updatedPoll.closed, answered, updatedPoll.public_voters); + updateVoters(updateTotalVoters); + voters.forEach((r) => { + const op = options.get(decoder.decode(r.option)); + if (op) { + getInterface(op).updateOption({ + voters: r, + answered, + closed: updatedPoll.closed, + maxVoters: updateMaxVoters, + totalVoters: updateTotalVoters, + }); + } + }); }; - updateTotalVotersText(pollData.closed, results.total_voters ?? 0); - updateVoteButtonText(pollData.closed, answered, pollData.public_voters); - updateVoters(results.total_voters ?? 0); + updatePollResults(pollData, results); info.classList.add('poll__message-info'); const container = div`.poll`( diff --git a/src/components/media/poll/poll_footer.scss b/src/components/media/poll/poll_footer.scss index 59aa1938..2cac9210 100644 --- a/src/components/media/poll/poll_footer.scss +++ b/src/components/media/poll/poll_footer.scss @@ -1,6 +1,6 @@ .pollFooter { display: grid; - margin: 8PX 0; + padding: 8PX 0; text-align: center; color: var(--accent-color); transition: color 0.3s; diff --git a/src/components/media/poll/poll_footer.ts b/src/components/media/poll/poll_footer.ts index a61a2a56..917623e6 100644 --- a/src/components/media/poll/poll_footer.ts +++ b/src/components/media/poll/poll_footer.ts @@ -5,9 +5,8 @@ import { pluralize } from 'helpers/other'; import './poll_footer.scss'; export enum VoteButtonState { - Inactive, Vote, - ShowResults, + ViewResults, } const voteText = 'VOTE'; @@ -20,30 +19,25 @@ function pluralizeVoters(quiz: boolean, voters: number) { : 'No answers'; } return voters > 0 - ? `${voters} ${pluralize(voters, 'vote', 'votess')}` + ? `${voters} ${pluralize(voters, 'vote', 'votes')}` : 'No votes'; } function formatStateText(state: VoteButtonState, voters: number, quiz: boolean, publicVoters: boolean, multipleChoice: boolean) { - if (state === VoteButtonState.Inactive || (!multipleChoice && !quiz)) { - return pluralizeVoters(quiz, voters); + switch (state) { + case VoteButtonState.Vote: + if (multipleChoice) { + return voteText; + } + return pluralizeVoters(quiz, voters); + case VoteButtonState.ViewResults: + if (publicVoters && voters > 0) { + return viewResultsText; + } + return pluralizeVoters(quiz, voters); + default: + return voteText; } - if (quiz) { - if (state === VoteButtonState.ShowResults && publicVoters) { - return viewResultsText; - } - return pluralizeVoters(quiz, voters); - } - - // Only multiple choice vote case left. - if (state === VoteButtonState.ShowResults) { - if (publicVoters) { - return viewResultsText; - } - return pluralizeVoters(quiz, voters); - } - - return voteText; } type Props = { @@ -51,39 +45,53 @@ type Props = { publicVoters: boolean, multipleChoice: boolean, onSubmit: () => void, - onShowResults: () => void, + onViewResults: () => void, }; -export default function pollFooter({ quiz, publicVoters, multipleChoice, onSubmit, onShowResults }: Props) { +export default function pollFooter({ quiz, publicVoters, multipleChoice, onSubmit, onViewResults }: Props) { let state = VoteButtonState.Vote; let voters = 0; + let optionsSelected = false; const stateText = text(''); - const container = ripple({}, [ - div`.pollFooter.-inactive`( - { - onClick: async () => { - if (state === VoteButtonState.ShowResults) { - onShowResults(); - } else { - onSubmit(); - } - }, + const footer = div`.pollFooter`( + { + onClick: async () => { + switch (state) { + case VoteButtonState.ViewResults: + if (voters > 0 && publicVoters) { + onViewResults(); + } + break; + case VoteButtonState.Vote: + if (multipleChoice) { + onSubmit(); + } + break; + default: + break; + } }, - stateText, - ), - ]); + }, + stateText, + ); + const container = ripple({}, [footer]); + + const setRippleEnable = getInterface(container).setEnabled; const updateState = () => { stateText.textContent = formatStateText(state, voters, quiz, publicVoters, multipleChoice); - getInterface(container).setEnabled(voters > 0 && state !== VoteButtonState.Inactive); + const enabled = (state === VoteButtonState.Vote && optionsSelected) + || (state === VoteButtonState.ViewResults && publicVoters && voters > 0); + setRippleEnable(enabled); + footer.classList.toggle('-inactive', !enabled); }; updateState(); return useInterface(container as HTMLElement, { - ...getInterface(container), - updateState: (newState: VoteButtonState) => { + updateState: (newState: VoteButtonState, newOptionsSelected: boolean) => { state = newState; + optionsSelected = newOptionsSelected; updateState(); }, updateVoters: (newVoters: number) => { diff --git a/src/components/message/message.scss b/src/components/message/message.scss index a2066414..000d2eee 100644 --- a/src/components/message/message.scss +++ b/src/components/message/message.scss @@ -100,6 +100,7 @@ $messageDistance: 4PX; transform: translateX(3px); margin-left: 5px; margin-top: 9px; + margin-bottom: 4px; } & .only-sticker &__info, & .only-photo &__info, & .only-audio &__info, & .only-document &__info, & .with-webpage &__info, & .with-webpage-media &__info { @@ -108,7 +109,7 @@ $messageDistance: 4PX; bottom: 3PX; z-index: 1; transform: none; - margin-bottom: 0; + margin-bottom: 2px; } & .only-audio &__info, & .only-document &__info { diff --git a/src/components/message/service.scss b/src/components/message/service.scss index b5c59e6b..e3a6ebb3 100644 --- a/src/components/message/service.scss +++ b/src/components/message/service.scss @@ -19,4 +19,4 @@ strong { font-weight: 500; } -} \ No newline at end of file +} diff --git a/src/components/popup/poll_results/poll_results.ts b/src/components/popup/poll_results/poll_results.ts index 7934f8dc..221051ab 100644 --- a/src/components/popup/poll_results/poll_results.ts +++ b/src/components/popup/poll_results/poll_results.ts @@ -4,7 +4,7 @@ import client from 'client/client'; import { listen } from 'core/dom'; import { getInterface } from 'core/hooks'; import { div, text } from 'core/html'; -import { peerMessageToId } from 'helpers/api'; +import { messageToId } from 'helpers/api'; import { Message, MessageUserVote, Peer } from 'mtproto-js'; import popupCommon from '../popup_common'; import './poll_results.scss'; @@ -12,13 +12,14 @@ import pollResultOption from './poll_result_option'; export type PollResultsContext = { peer: Peer, - message: Message.message, + messageId: string, }; const decoder = new TextDecoder(); -export default function pollResultsPopup({ peer, message }: PollResultsContext) { - if (message.media?._ !== 'messageMediaPoll') { +export default function pollResultsPopup({ peer, messageId }: PollResultsContext) { + const message = messageCache.get(messageId); + if (message?._ !== 'message' || message.media?._ !== 'messageMediaPoll') { throw new Error('message media must be of type "messageMediaPoll"'); } const { poll } = message.media; @@ -53,7 +54,7 @@ export default function pollResultsPopup({ peer, message }: PollResultsContext) } }; - messageCache.watchItem(peerMessageToId(peer, message.id), (msg) => { + messageCache.watchItem(messageToId(message), (msg) => { updateOptions(msg); }); @@ -87,7 +88,7 @@ export default function pollResultsPopup({ peer, message }: PollResultsContext) const answer = poll.answers[index]; const request = { peer: peerToInputPeer(peer), - id: message.id, + id: message!.id, limit: 50, option: answer.option, }; diff --git a/src/helpers/data.ts b/src/helpers/data.ts index 5a7f3b35..4eea3448 100644 --- a/src/helpers/data.ts +++ b/src/helpers/data.ts @@ -94,6 +94,10 @@ export function getFirstLetter(str: string) { if (str[i] >= '0' && str[i] <= '9') { return str[i]; } + // surrogate pair (U+D800 to U+DFFF) + if ((str.charCodeAt(i) & 0xf800) === 0xd800) { + return str.slice(i, 2); + } } return ''; @@ -115,6 +119,7 @@ export function getFirstLetters(title: string) { } export function areIteratorsEqual(it1: Iterator, it2: Iterator): boolean { + // eslint-disable-next-line no-constant-condition while (true) { const result1 = it1.next(); const result2 = it2.next(); diff --git a/src/services/main.ts b/src/services/main.ts index 2aea3311..214734e9 100644 --- a/src/services/main.ts +++ b/src/services/main.ts @@ -1,6 +1,6 @@ import client from 'client/client'; import { PhotoOptions } from 'helpers/other'; -import { Document, InputStickerSet, Message, MessageMedia, Peer, Photo } from 'mtproto-js'; +import { Document, InputStickerSet, Message, Peer, Photo } from 'mtproto-js'; import { BehaviorSubject } from 'rxjs'; type SidebarState = import('components/sidebar/sidebar').SidebarState; @@ -31,7 +31,7 @@ export default class MainService { showPopup(type: 'stickerSet', ctx: InputStickerSet): void; showPopup(type: 'photo', ctx: { rect: DOMRect, options: PhotoOptions, photo: Photo, peer: Peer, message: Message }): void; showPopup(type: 'video', ctx: { rect: DOMRect, video: Document.document, peer?: Peer, message?: Message }): void; - showPopup(type: 'pollResults', ctx: { peer: Peer, message: Message.message, poll: MessageMedia.messageMediaPoll }): void; + showPopup(type: 'pollResults', ctx: { peer: Peer, messageId: string }): void; showPopup(type: string, ctx?: any): void { this.popupCtx = ctx; this.popup.next(type); diff --git a/src/services/polls.ts b/src/services/polls.ts index 84720410..97f94242 100644 --- a/src/services/polls.ts +++ b/src/services/polls.ts @@ -1,12 +1,31 @@ -import client from 'client/client'; -import { Update, Peer } from 'mtproto-js'; +import { chatCache, messageCache, userCache } from 'cache'; import { peerToInputPeer } from 'cache/accessors'; -import { messageCache, userCache, chatCache } from 'cache'; +import client from 'client/client'; +import { messageToId } from 'helpers/api'; +import { Peer, Update } from 'mtproto-js'; export default class PollsService { constructor() { client.updates.on('updateMessagePoll', this.processUpdateMessagePoll); client.updates.on('updateMessagePollVote', this.processUpdateMessagePollVote); + + // Telegram doesn't send poll update when poll is closed by timeout. Thus we have to setup a timer which triggers poll close. + messageCache.changes.subscribe((changes) => { + changes.forEach(([changeType, message]) => { + if (changeType === 'add') { + if (message?._ === 'message' && message?.media?._ === 'messageMediaPoll') { + if (message.media.poll.close_period) { + this.schedulePollClose(messageToId(message), message.media.poll.close_period * 1000); + } else if (message.media.poll.close_date) { + const timeout = new Date(message.media.poll.close_date * 1000).getTime() - Date.now(); + if (timeout > 0) { + this.schedulePollClose(messageToId(message), timeout); + } + } + } + } + }); + }); } public async sendVote(peer: Peer, messageId: number, options: ArrayBuffer[]) { @@ -26,6 +45,29 @@ export default class PollsService { } } + private schedulePollClose(messageId: string, timeout: number) { + setTimeout(() => { + const latestMessage = messageCache.get(messageId); + if (latestMessage?._ === 'message' && latestMessage?.media?._ === 'messageMediaPoll') { + if (latestMessage.media.poll.closed) { + return; + } + const { media, media: { poll } } = latestMessage; + const closedPollMessage = { + ...latestMessage, + media: { + ...media, + poll: { + ...poll, + closed: true, + }, + }, + }; + messageCache.change(messageId, closedPollMessage); + } + }, timeout); + } + private processUpdateMessagePoll = (update: Update.updateMessagePoll) => { messageCache.indices.polls.updatePoll(update); }; From cd10c565443c6d17686514f0fed4041cdc168f76 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sat, 30 May 2020 14:56:37 +0300 Subject: [PATCH 24/26] Review fixes --- src/cache/fastStorages/indices/pollsIndex.ts | 9 ++-- src/components/media/poll/poll.ts | 10 ++--- src/components/message/message.ts | 43 +++++++++---------- .../popup/poll_results/poll_results.ts | 4 +- src/services/polls.ts | 14 +++--- 5 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/cache/fastStorages/indices/pollsIndex.ts b/src/cache/fastStorages/indices/pollsIndex.ts index fd1a47a3..28fa2fc4 100644 --- a/src/cache/fastStorages/indices/pollsIndex.ts +++ b/src/cache/fastStorages/indices/pollsIndex.ts @@ -1,13 +1,12 @@ -import { messageToId } from 'helpers/api'; import { Message, PollResults, Update } from 'mtproto-js'; import Collection from '../collection'; const decoder = new TextDecoder(); -export default function pollsIndex(collection: Collection) { +export default function pollsIndex(collection: Collection) { const pollMessageIds = new Map>(); collection.changes.subscribe((collectionChanges) => { - collectionChanges.forEach(([action, item]) => { + collectionChanges.forEach(([action, item, key]) => { if (item._ !== 'message' || item.media?._ !== 'messageMediaPoll') { return; } @@ -17,13 +16,13 @@ export default function pollsIndex(collection: Collection) { if (!messageIds) { pollMessageIds.set(item.media.poll.id, messageIds = new Set()); } - messageIds.add(messageToId(item)); + messageIds.add(key); break; } case 'remove': { const messageIds = pollMessageIds.get(item.media.poll.id); if (messageIds) { - messageIds.delete(messageToId(item)); + messageIds.delete(key); if (messageIds.size === 0) { pollMessageIds.delete(item.media.poll.id); } diff --git a/src/components/media/poll/poll.ts b/src/components/media/poll/poll.ts index 5b5d8abd..94208bcb 100644 --- a/src/components/media/poll/poll.ts +++ b/src/components/media/poll/poll.ts @@ -40,7 +40,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const pollData = media.poll as Required; const selectedOptions = new Set(); const pollOptions: ReturnType[] = []; - const pollTypeText = text(pollType(pollData)); + const pollHeader = text(pollType(pollData)); const recentVoters = div`poll__recent-voters`(...buildRecentVotersList(results.recent_voters)); const options = new Map(); let answered = !!results.results && results.results.findIndex((r) => r.chosen) >= 0; @@ -83,7 +83,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle voters, maxVoters, totalVoters: results.total_voters ?? 0, - clickCallback: async (selected) => { + clickCallback: (selected) => { if (pollData.multiple_choice) { const optKey = decoder.decode(answer.option); if (selected) { @@ -94,7 +94,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle getInterface(voteFooterEl).updateState(answered ? VoteButtonState.ViewResults : VoteButtonState.Vote, selectedOptions.size > 0); } else { selectedOptions.add(decoder.decode(answer.option)); - await submitOptions(); + submitOptions(); } }, }); @@ -117,7 +117,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const updatePollResults = (updatedPoll: Required, updatedResults: PollResults) => { const updateTotalVoters = updatedResults.total_voters ?? 0; if (updatedPoll) { - pollTypeText.textContent = pollType(updatedPoll); + pollHeader.textContent = pollType(updatedPoll); } const voters = updatedResults.results ?? []; unmountChildren(recentVoters); @@ -148,7 +148,7 @@ export default function poll(peer: Peer, message: Message.message, info: HTMLEle const container = div`.poll`( div`.poll__body`( div`.poll__question`(text(pollData.question)), - div`poll__info`(div`poll__type`(pollTypeText), recentVoters), + div`poll__info`(div`poll__type`(pollHeader), recentVoters), div`.poll__options`(...pollOptions), ), div`.poll__footer`( diff --git a/src/components/message/message.ts b/src/components/message/message.ts index 443da940..2bc0825b 100644 --- a/src/components/message/message.ts +++ b/src/components/message/message.ts @@ -1,30 +1,30 @@ -import { div, text, nothing, span } from 'core/html'; -import { useInterface, hasInterface, getInterface, useOnMount } from 'core/hooks'; -import { mount, unmount } from 'core/dom'; -import { messageCache, dialogCache } from 'cache'; -import { Peer, Message, Dialog } from 'mtproto-js'; -import { formattedMessage, bubble, messageInfo, MessageInfoInterface } from 'components/ui'; -import { profileAvatar, profileTitle } from 'components/profile'; -import webpagePreview from 'components/media/webpage/preview'; +import { dialogCache, messageCache } from 'cache'; +import { messageToSenderPeer, peerToColorCode } from 'cache/accessors'; +import { useContextMenu } from 'components/global_context_menu'; +import * as icons from 'components/icons'; +import audio from 'components/media/audio/audio'; +import documentFile from 'components/media/document/file'; import photoPreview from 'components/media/photo/preview'; -import { getAttributeSticker, getAttributeVideo, getAttributeAnimated, getAttributeAudio } from 'helpers/files'; +import poll from 'components/media/poll/poll'; import stickerRenderer from 'components/media/sticker/sticker'; -import documentFile from 'components/media/document/file'; import videoPreview from 'components/media/video/preview'; import videoRenderer from 'components/media/video/video'; -import audio from 'components/media/audio/audio'; -import poll from 'components/media/poll/poll'; -import { messageToSenderPeer, peerToColorCode } from 'cache/accessors'; -import { userIdToPeer, peerToId } from 'helpers/api'; +import webpagePreview from 'components/media/webpage/preview'; +import { profileAvatar, profileTitle } from 'components/profile'; +import { bubble, formattedMessage, messageInfo, MessageInfoInterface } from 'components/ui'; +import { mount, unmount } from 'core/dom'; +import { getInterface, hasInterface, useInterface, useOnMount } from 'core/hooks'; +import { div, nothing, span, text } from 'core/html'; +import { peerToId, userIdToPeer } from 'helpers/api'; +import { getAttributeAnimated, getAttributeAudio, getAttributeSticker, getAttributeVideo } from 'helpers/files'; import { isEmoji } from 'helpers/message'; -import { main, message as service } from 'services'; import { todoAssertHasValue } from 'helpers/other'; -import { useContextMenu } from 'components/global_context_menu'; -import * as icons from 'components/icons'; -import messageSerivce from './service'; +import { Dialog, Message, Peer } from 'mtproto-js'; +import { main, message as service } from 'services'; +import './message.scss'; import messageReply from './reply'; import replyMarkupRenderer from './reply_markup'; -import './message.scss'; +import messageSerivce from './service'; type MessageInterface = { from(): number, @@ -203,10 +203,9 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: // with poll if (msg.media._ === 'messageMediaPoll') { - const extraClass = 'with-poll'; return { message: bubble( - { out, className: extraClass }, + { out, className: 'with-poll' }, reply, poll(peer, msg, info), ), @@ -214,7 +213,7 @@ const renderMessage = (msg: Message.message, peer: Peer): { message: Node, info: }; } - console.log(msg); + // console.log(msg); // fallback return { diff --git a/src/components/popup/poll_results/poll_results.ts b/src/components/popup/poll_results/poll_results.ts index 221051ab..f9eff40c 100644 --- a/src/components/popup/poll_results/poll_results.ts +++ b/src/components/popup/poll_results/poll_results.ts @@ -2,7 +2,7 @@ import { messageCache, userCache } from 'cache'; import { peerToInputPeer } from 'cache/accessors'; import client from 'client/client'; import { listen } from 'core/dom'; -import { getInterface } from 'core/hooks'; +import { getInterface, useObservable } from 'core/hooks'; import { div, text } from 'core/html'; import { messageToId } from 'helpers/api'; import { Message, MessageUserVote, Peer } from 'mtproto-js'; @@ -54,7 +54,7 @@ export default function pollResultsPopup({ peer, messageId }: PollResultsContext } }; - messageCache.watchItem(messageToId(message), (msg) => { + useObservable(container, messageCache.useItemBehaviorSubject(container, messageToId(message)), (msg) => { updateOptions(msg); }); diff --git a/src/services/polls.ts b/src/services/polls.ts index 97f94242..44c9341d 100644 --- a/src/services/polls.ts +++ b/src/services/polls.ts @@ -1,7 +1,6 @@ import { chatCache, messageCache, userCache } from 'cache'; import { peerToInputPeer } from 'cache/accessors'; import client from 'client/client'; -import { messageToId } from 'helpers/api'; import { Peer, Update } from 'mtproto-js'; export default class PollsService { @@ -11,16 +10,17 @@ export default class PollsService { // Telegram doesn't send poll update when poll is closed by timeout. Thus we have to setup a timer which triggers poll close. messageCache.changes.subscribe((changes) => { - changes.forEach(([changeType, message]) => { + changes.forEach(([changeType, message, id]) => { if (changeType === 'add') { if (message?._ === 'message' && message?.media?._ === 'messageMediaPoll') { + let timeout = 0; if (message.media.poll.close_period) { - this.schedulePollClose(messageToId(message), message.media.poll.close_period * 1000); + timeout = message.media.poll.close_period * 1000 - (Date.now() - message.date * 1000); } else if (message.media.poll.close_date) { - const timeout = new Date(message.media.poll.close_date * 1000).getTime() - Date.now(); - if (timeout > 0) { - this.schedulePollClose(messageToId(message), timeout); - } + timeout = message.media.poll.close_date * 1000 - Date.now(); + } + if (timeout > 0) { + this.schedulePollClose(id, timeout); } } } From 904bd4795fcc54b6f8080a07ade6ba5fd270a94c Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sat, 30 May 2020 20:58:54 +0300 Subject: [PATCH 25/26] Review fixes --- src/components/media/poll/poll.scss | 21 ++++++++----------- src/components/media/poll/poll_checkbox.ts | 7 +++++-- src/components/media/poll/poll_option.scss | 16 ++++++++------ src/components/media/poll/poll_option.ts | 2 +- .../popup/poll_results/poll_results.ts | 4 ++-- src/core/dom.ts | 2 +- src/helpers/data.ts | 7 ++++--- src/helpers/emoji.ts | 9 ++++++++ 8 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/components/media/poll/poll.scss b/src/components/media/poll/poll.scss index cf7e908c..e99d1fbe 100644 --- a/src/components/media/poll/poll.scss +++ b/src/components/media/poll/poll.scss @@ -2,11 +2,11 @@ user-select: none; font-size: 1rem; - & &__body { + &__body { padding: 7PX 10PX 0 10PX; } - & &__footer { + &__footer { display: grid; >* { @@ -25,17 +25,17 @@ font-weight: 500; } - & &__info { + &__info { display: flex; align-items: center; } - & &__type { + &__type { color: var(--accent-color-inactive); font-size: 0.9rem; } - & &__recent-voters { + &__recent-voters { padding-left: 8px; display: flex; flex-direction: row-reverse; @@ -53,20 +53,17 @@ min-width: 18px; min-height: 18px; border: 1px solid var(--bubble-background); - } - - .avatar.-standard { - font-size: 0.6rem; + --initials-font-size: 0.6rem; } } - & &__voters { + &__voters { color: var(--accent-color-inactive); font-size: 0.9rem; padding: 10px 0 4px 10px; } - & &__vote-button { + &__vote-button { margin: 8PX 0; text-align: center; color: var(--accent-color); @@ -74,7 +71,7 @@ cursor: pointer; } - & &__vote-button.-inactive { + &__vote-button.-inactive { color: var(--accent-color-inactive); cursor: default; } diff --git a/src/components/media/poll/poll_checkbox.ts b/src/components/media/poll/poll_checkbox.ts index a7ff84e1..8453d719 100644 --- a/src/components/media/poll/poll_checkbox.ts +++ b/src/components/media/poll/poll_checkbox.ts @@ -1,5 +1,5 @@ import { checkbox } from 'components/ui'; -import { mount, unmount } from 'core/dom'; +import { animationFrameStart, mount, unmount } from 'core/dom'; import { svgCodeToComponent } from 'core/factory'; import { getInterface, useInterface } from 'core/hooks'; import { div } from 'core/html'; @@ -29,7 +29,10 @@ export default function pollCheckbox({ multiple, clickCallback }: Props) { onChange: (checked) => { unmount(cb); const effect = div`.pollCheckbox__ripple`({ - onAnimationEnd: () => setTimeout(() => unmount(effect), 1000), + onAnimationEnd: async () => { + await animationFrameStart(); + unmount(effect); + }, }); mount(container, effect); mount(container, spinner); diff --git a/src/components/media/poll/poll_option.scss b/src/components/media/poll/poll_option.scss index 473f4382..281a212b 100644 --- a/src/components/media/poll/poll_option.scss +++ b/src/components/media/poll/poll_option.scss @@ -16,12 +16,12 @@ stroke: var(--color); } - & &__text { + &__text { grid-column: 2; margin: auto 0 12px 8px; } - & &__percentage { + &__percentage { grid-row: 1; grid-column: 1; margin: auto 0 12px 0; @@ -33,7 +33,7 @@ cursor: default; } - & &__checkbox { + &__checkbox { grid-row: 1; grid-column: 1; margin: auto; @@ -41,7 +41,7 @@ transition: opacity 0.3s; } - & &__answer { + &__answer { background-color: var(--color, var(--accent-color)); border-radius: 100%; grid-row: 1; @@ -66,16 +66,20 @@ } } - & &__answer.-hidden { + &__answer.-hidden { opacity: 0; } - & &__line { + &__line { grid-row: 1; grid-column: 1 / 2; align-self: end; opacity: 0; transition: opacity 0.1s; + svg { + width: 300px; + height: 30px; + } path { stroke: var(--color, var(--accent-color)); stroke-width: 4px; diff --git a/src/components/media/poll/poll_option.ts b/src/components/media/poll/poll_option.ts index 19ff5415..92ecac3c 100644 --- a/src/components/media/poll/poll_option.ts +++ b/src/components/media/poll/poll_option.ts @@ -44,7 +44,7 @@ export default function pollOption(initialProps: Props) { const checkbox = span`.pollOption__checkbox`(checkboxEl); const percentage = span`.pollOption__percentage`(); const answer = answerIcon(); - const line = svgEl('svg', { width: 300, height: 30, class: 'pollOption__line' }, [ + const line = svgEl('svg', { class: 'pollOption__line' }, [ path = svgEl('path', { d: 'M20 8 v 3.5 a 13 13 0 0 0 13 13 H 300' }), ]); const container = label`.pollOption`( diff --git a/src/components/popup/poll_results/poll_results.ts b/src/components/popup/poll_results/poll_results.ts index f9eff40c..720d48fd 100644 --- a/src/components/popup/poll_results/poll_results.ts +++ b/src/components/popup/poll_results/poll_results.ts @@ -2,7 +2,7 @@ import { messageCache, userCache } from 'cache'; import { peerToInputPeer } from 'cache/accessors'; import client from 'client/client'; import { listen } from 'core/dom'; -import { getInterface, useObservable } from 'core/hooks'; +import { getInterface } from 'core/hooks'; import { div, text } from 'core/html'; import { messageToId } from 'helpers/api'; import { Message, MessageUserVote, Peer } from 'mtproto-js'; @@ -54,7 +54,7 @@ export default function pollResultsPopup({ peer, messageId }: PollResultsContext } }; - useObservable(container, messageCache.useItemBehaviorSubject(container, messageToId(message)), (msg) => { + messageCache.useItemBehaviorSubject(container, messageToId(message)).subscribe((msg) => { updateOptions(msg); }); diff --git a/src/core/dom.ts b/src/core/dom.ts index 4fe87238..30622848 100644 --- a/src/core/dom.ts +++ b/src/core/dom.ts @@ -343,7 +343,7 @@ export function watchVisibility(element: Element, onChange: (isVisible: boolean) /** * Makes a promise that resolves right at the start of an animation frame start - * (in contract to requestAnimationFrame that fires at the and of the animation frame). + * (in contrast to requestAnimationFrame that fires at the and of the animation frame). * Use it to run a heavy task during or right after an animation or a transition. * * @param calledFromRafCallback Set to true when you are sure that this function is called from a requestAnimationFrame callback. diff --git a/src/helpers/data.ts b/src/helpers/data.ts index 4eea3448..9daf6b00 100644 --- a/src/helpers/data.ts +++ b/src/helpers/data.ts @@ -1,4 +1,5 @@ import binarySearch from 'binary-search'; +import { getSurrogatePairAtIndex } from './emoji'; /* Helper functions to work with a pure data not related to any business logic @@ -94,9 +95,9 @@ export function getFirstLetter(str: string) { if (str[i] >= '0' && str[i] <= '9') { return str[i]; } - // surrogate pair (U+D800 to U+DFFF) - if ((str.charCodeAt(i) & 0xf800) === 0xd800) { - return str.slice(i, 2); + const surrogatePair = getSurrogatePairAtIndex(str, i); + if (surrogatePair) { + return surrogatePair; } } diff --git a/src/helpers/emoji.ts b/src/helpers/emoji.ts index 76d00f61..8cb8cd16 100644 --- a/src/helpers/emoji.ts +++ b/src/helpers/emoji.ts @@ -17,3 +17,12 @@ export function getEmojiImageUrl(emoji: string, style: EmojiStyle = 'apple'): st .join('-'); return `https://cdn.jsdelivr.net/npm/emoji-datasource-apple@4.1.0/img/${style}/64/${emojiCode}.png`; } + +export function getSurrogatePairAtIndex(str: string, index: number): string | undefined { + // surrogate pair (U+D800 to U+DFFF) + if ((str.charCodeAt(index) & 0xf800) === 0xd800) { + return str.slice(index, 2); + } + + return undefined; +} From 297485a83b7502b216c6f09020bccda4ad7e2692 Mon Sep 17 00:00:00 2001 From: Mikhail Isupov Date: Sat, 30 May 2020 23:27:01 +0300 Subject: [PATCH 26/26] Style fix --- src/components/media/poll/poll_option.scss | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/media/poll/poll_option.scss b/src/components/media/poll/poll_option.scss index 281a212b..4d4b76de 100644 --- a/src/components/media/poll/poll_option.scss +++ b/src/components/media/poll/poll_option.scss @@ -55,9 +55,11 @@ justify-items: center; opacity: 1; transition: opacity 0.3s 0.2s; + svg { width: 10px; height: 10px; + path { fill: white; stroke: white; @@ -76,10 +78,9 @@ align-self: end; opacity: 0; transition: opacity 0.1s; - svg { - width: 300px; - height: 30px; - } + width: 300px; + height: 30px; + path { stroke: var(--color, var(--accent-color)); stroke-width: 4px; @@ -89,12 +90,12 @@ stroke-dashoffset: 1; } } - + &__checkbox.-answered { opacity: 0; pointer-events: none; } - + &__percentage.-answered { opacity: 1; } @@ -103,7 +104,7 @@ opacity: 1; } - .checkbox__input:checked + .checkbox__box { + .checkbox__input:checked+.checkbox__box { border-color: var(--accent-color); background: var(--accent-color); }