diff --git a/frontend/common/common.js b/frontend/common/common.js index eda809c3ee..16e31aa221 100644 --- a/frontend/common/common.js +++ b/frontend/common/common.js @@ -22,8 +22,6 @@ // Doing otherwise defeats the purpose of this file and could lead to bugs and conflicts! // You may *add* behavior, but never modify or remove it. -export { default as Vue } from 'vue' -export { default as L } from './translations.js' export * from './translations.js' export * from './errors.js' export * as Errors from './errors.js' diff --git a/frontend/common/translations.js b/frontend/common/translations.js index 67a559ecce..1872c2447f 100644 --- a/frontend/common/translations.js +++ b/frontend/common/translations.js @@ -2,30 +2,12 @@ // since this file is loaded by common.js, we avoid circular imports and directly import import sbp from '@sbp/sbp' -import Vue from 'vue' -import dompurify from 'dompurify' -import { defaultConfig as defaultDompurifyConfig } from './vSafeHtml.js' import template from './stringTemplate.js' -Vue.prototype.L = L -Vue.prototype.LTags = LTags - const defaultLanguage = 'en-US' const defaultLanguageCode = 'en' const defaultTranslationTable: { [string]: string } = {} -/** - * Allow 'href' and 'target' attributes to avoid breaking our hyperlinks, - * but keep sanitizing their values. - * See https://github.com/cure53/DOMPurify#can-i-configure-dompurify - */ -const dompurifyConfig = { - ...defaultDompurifyConfig, - ALLOWED_ATTR: ['class', 'href', 'rel', 'target'], - ALLOWED_TAGS: ['a', 'b', 'br', 'button', 'em', 'i', 'p', 'small', 'span', 'strong', 'sub', 'sup', 'u'], - RETURN_DOM_FRAGMENT: false -} - let currentLanguage = defaultLanguage let currentLanguageCode = defaultLanguage.split('-')[0] let currentTranslationTable = defaultTranslationTable @@ -40,7 +22,7 @@ let currentTranslationTable = defaultTranslationTable * * @see https://tools.ietf.org/rfc/bcp/bcp47.txt */ -sbp('sbp/selectors/register', { +export default (sbp('sbp/selectors/register', { 'translations/init': async function init (language: string): Promise { // A language code is usually the first part of a language tag. const [languageCode] = language.toLowerCase().split('-') @@ -69,7 +51,7 @@ sbp('sbp/selectors/register', { console.error(error) } } -}) +}): string[]) /* Examples: @@ -122,19 +104,22 @@ export function LTags (...tags: string[]): {|br_: string|} { return o } -export default function L ( +export function L ( key: string, args: Array<*> | Object | void ): string { return template(currentTranslationTable[key] || key, args) // Avoid inopportune linebreaks before certain punctuations. - .replace(/\s(?=[;:?!])/g, ' ') + // '\u00a0' is a non-breaking space + // The character is used instead of ` ` or ` ` for conciseness + // and compatibility. + .replace(/\s(?=[;:?!])/g, '\u00a0') } export function LError (error: Error, toGithub?: boolean): {|reportError: any|} { let url = 'https://github.com/okTurtles/group-income/issues' if (!toGithub && sbp('state/vuex/state').loggedIn) { - const baseRoute = document.location.origin + sbp('controller/router').options.base + const baseRoute = sbp('controller/router').options.base url = `${baseRoute}?modal=UserSettingsModal&tab=application-logs&errorMsg=${encodeURIComponent(error.message)}` } return { @@ -145,49 +130,3 @@ export function LError (error: Error, toGithub?: boolean): {|reportError: any|} }) } } - -function sanitize (inputString) { - return dompurify.sanitize(inputString, dompurifyConfig) -} - -Vue.component('i18n', { - functional: true, - props: { - args: [Object, Array], - tag: { - type: String, - default: 'span' - }, - compile: Boolean - }, - render: function (h, context) { - const text = context.children[0].text - const translation = L(text, context.props.args || {}) - if (!translation) { - console.warn('The following i18n text was not translated correctly:', text) - return h(context.props.tag, context.data, text) - } - // Prevent reverse tabnabbing by including `rel="noopener noreferrer"` when rendering as an outbound hyperlink. - if (context.props.tag === 'a' && context.data.attrs.target === '_blank') { - context.data.attrs.rel = 'noopener noreferrer' - } - if (context.props.compile) { - const result = Vue.compile('' + sanitize(translation) + '') - // console.log('TRANSLATED RENDERED TEXT:', context, result.render.toString()) - return result.render.call({ - _c: (tag, ...args) => { - if (tag === 'wrap') { - return h(context.props.tag, context.data, ...args) - } else { - return h(tag, ...args) - } - }, - _v: x => x - }) - } else { - if (!context.data.domProps) context.data.domProps = {} - context.data.domProps.innerHTML = sanitize(translation) - return h(context.props.tag, context.data) - } - } -}) diff --git a/frontend/controller/app/chatroom.js b/frontend/controller/app/chatroom.js index 2971c86675..227fec45cc 100644 --- a/frontend/controller/app/chatroom.js +++ b/frontend/controller/app/chatroom.js @@ -14,7 +14,22 @@ sbp('okTurtles.events/on', JOINED_CHATROOM, ({ identityContractID, groupContract const rootState = sbp('state/vuex/state') if (rootState.loggedIn?.identityContractID !== identityContractID) return if (!rootState.chatroom.currentChatRoomIDs[groupContractID] || rootState.chatroom.pendingChatRoomIDs[groupContractID] === chatRoomID) { - sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupID: groupContractID, chatRoomID }) + let attemptCount = 0 + // Sometimes, the state may not be ready (it needs to be copied from the SW + // to Vuex). In this case, we try again after a short delay. + // TODO: Figure out a better way of doing this that doesn't require a timeout + const setCurrentChatRoomId = () => { + if (!rootState[chatRoomID]?.members?.[identityContractID]) { + if (++attemptCount > 5) { + console.warn('[JOINED_CHATROOM] Given up on setCurrentChatRoomId after 5 attempts', { identityContractID, groupContractID, chatRoomID }) + return + } + setTimeout(setCurrentChatRoomId, 5 + 5 * attemptCount) + } else { + sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupID: groupContractID, chatRoomID }) + } + } + setCurrentChatRoomId() } }) sbp('okTurtles.events/on', LEFT_CHATROOM, switchCurrentChatRoomHandler) diff --git a/frontend/controller/app/identity.js b/frontend/controller/app/identity.js index 8adf4403fc..acbbb35008 100644 --- a/frontend/controller/app/identity.js +++ b/frontend/controller/app/identity.js @@ -1,9 +1,9 @@ 'use strict' -import * as Common from '@common/common.js' import { GIErrorUIRuntimeError, L, LError, LTags } from '@common/common.js' import { cloneDeep } from '@model/contracts/shared/giLodash.js' import sbp from '@sbp/sbp' +import Vue from 'vue' import { LOGIN, LOGIN_COMPLETE, LOGIN_ERROR } from '~/frontend/utils/events.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' @@ -11,8 +11,6 @@ import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSal import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, serializeKey } from '../../../shared/domains/chelonia/crypto.js' import { handleFetchResult } from '../utils/misc.js' -const { Vue } = Common - const loadState = async (identityContractID: string, password: ?string) => { if (password) { const stateKeyEncryptionKeyFn = (stateEncryptionKeyId, salt) => { diff --git a/frontend/controller/router.js b/frontend/controller/router.js index 0f2c1ca894..fd87d23877 100644 --- a/frontend/controller/router.js +++ b/frontend/controller/router.js @@ -1,12 +1,13 @@ 'use strict' import sbp from '@sbp/sbp' -import { Vue, L } from '@common/common.js' +import { L } from '@common/common.js' import Router from 'vue-router' import store from '~/frontend/model/state.js' import Home from '@pages/Home.vue' import Join from '@pages/Join.vue' import { lazyPage } from '@utils/lazyLoadedView.js' +import Vue from 'vue' /* * Lazy load all the pages that are not necessary at initial loading of the app. diff --git a/frontend/controller/service-worker.js b/frontend/controller/service-worker.js index 79f5e24b2d..14d768ae13 100644 --- a/frontend/controller/service-worker.js +++ b/frontend/controller/service-worker.js @@ -26,35 +26,50 @@ window.addEventListener('beforeinstallprompt', e => { sbp('sbp/selectors/register', { 'service-workers/setup': async function () { + console.error('@@@SW SETUP') // setup service worker // TODO: move ahead with encryption stuff ignoring this service worker stuff for now // TODO: improve updating the sw: https://stackoverflow.com/a/49748437 // NOTE: user should still be able to use app even if all the SW stuff fails - if (!('serviceWorker' in navigator)) { return } + if (!('serviceWorker' in navigator)) { + throw new Error('Service worker APIs missing') + } try { const swRegistration = await navigator.serviceWorker.register('/assets/js/sw-primary.js', { scope: '/' }) - if (swRegistration) { - swRegistration.active?.postMessage({ type: 'store-client-id' }) + console.error('@@@SW swRegistration') - // if an active service-worker exists, checks for the updates immediately first and then repeats it every 1hr - await swRegistration.update() - setInterval(() => sbp('service-worker/update'), HOURS_MILLIS) - - // Keep the service worker alive while the window is open - // The default idle timeout on Chrome and Firefox is 30 seconds. We send - // a ping message every 5 seconds to ensure that the worker remains - // active. - // The downside of this is that there are messges going back and forth - // between the service worker and each tab, the number of which is - // proportional to the number of tabs open. - // The upside of this is that the service worker remains active while - // there are open tabs, which makes it faster and smoother to interact - // with contracts than if the service worker had to be restarted. - setInterval(() => navigator.serviceWorker.controller?.postMessage({ type: 'ping' }), 5000) + if (swRegistration.active) { + console.error('@@@SW swRegistration active') + swRegistration.active?.postMessage({ type: 'store-client-id' }) + } else { + console.error('@@@SW swRegistration handling') + const handler = () => { + navigator.serviceWorker.removeEventListener('controllerchange', handler, false) + navigator.serviceWorker.controller.postMessage({ type: 'store-client-id' }) + } + navigator.serviceWorker.addEventListener('controllerchange', handler, false) } + // if an active service-worker exists, checks for the updates immediately first and then repeats it every 1hr + await swRegistration.update() + setInterval(() => sbp('service-worker/update'), HOURS_MILLIS) + + // Keep the service worker alive while the window is open + // The default idle timeout on Chrome and Firefox is 30 seconds. We send + // a ping message every 5 seconds to ensure that the worker remains + // active. + // The downside of this is that there are messges going back and forth + // between the service worker and each tab, the number of which is + // proportional to the number of tabs open. + // The upside of this is that the service worker remains active while + // there are open tabs, which makes it faster and smoother to interact + // with contracts than if the service worker had to be restarted. + setInterval(() => navigator.serviceWorker.controller?.postMessage({ type: 'ping' }), 5000) + + console.error('@@@SW swRegistration 70') + navigator.serviceWorker.addEventListener('message', event => { const data = event.data @@ -78,6 +93,8 @@ sbp('sbp/selectors/register', { } } }) + + console.error('@@@SW DONE, returning') } catch (e) { console.error('error setting up service worker:', e) } @@ -94,6 +111,8 @@ sbp('sbp/selectors/register', { } const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) + if (!pubsub) return // TODO: This needs to be moved into the service worker + // proper. pubsub will be undefined in this context. const existingSubscription = await registration.pushManager.getSubscription() const messageToPushServerIfSocketConnected = (msgPayload) => { // make sure the websocket client is not in the state of CLOSING, CLOSED before sending a message. diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index 2dacb4065d..dd9100f3c5 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -60,6 +60,51 @@ sbp('sbp/selectors/register', { }, 'state/vuex/commit': () => { console.error('[sw] CALLED state/vuex/commit WHICH IS UNDEFINED') + }, + 'state/vuex/getters': () => { + return { + chatRoomUnreadMessages () { + return [] + }, + getChatroomNameById () { + return '' + }, + groupIdFromChatRoomId () { + return '' + }, + isGroupDirectMessage () { + return false + }, + notificationsByGroup () { + return [] + }, + ourContactProfilesByUsername: {}, + ourIdentityContractId: '', + userDisplayNameFromID () { + return '' + }, + usernameFromID () { + return '' + } + } + } +}) + +sbp('sbp/selectors/register', { + 'controller/router': () => { + return { options: { base: '/app/' } } + } +}) + +sbp('sbp/selectors/register', { + 'gi.ui/seriousErrorBanner': (...args) => { + console.error('### SERIOUS ERROR ###', ...args) + } +}) + +sbp('sbp/selectors/register', { + 'gi.notifications/emit': (...args) => { + console.error('### notification ###', ...args) } }) diff --git a/frontend/main.js b/frontend/main.js index 6509724b4f..d1d348787a 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -1,7 +1,7 @@ 'use strict' // import SBP stuff before anything else so that domains register themselves before called -import * as Common from '@common/common.js' +import { L, LError } from '@common/common.js' import '@model/captureLogs.js' import '@sbp/okturtles.data' import '@sbp/okturtles.eventqueue' @@ -29,6 +29,7 @@ import Modal from './views/components/modal/Modal.vue' import BackgroundSounds from './views/components/sounds/Background.vue' import Navigation from './views/containers/navigation/Navigation.vue' import './views/utils/avatar.js' +import './views/utils/i18n.js' import './views/utils/ui.js' import './views/utils/vError.js' import './views/utils/vFocus.js' @@ -36,6 +37,7 @@ import './views/utils/vFocus.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' import { deserializer, serializer } from '~/shared/serdes/index.js' +import Vue from 'vue' import notificationsMixin from './model/notifications/mainNotificationsMixin.js' import './model/notifications/periodicNotifications.js' import FaviconBadge from './utils/faviconBadge.js' @@ -46,8 +48,6 @@ import './views/utils/vStyle.js' deserializer.register(GIMessage) deserializer.register(Secret) -const { Vue, L, LError } = Common - console.info('GI_VERSION:', process.env.GI_VERSION) console.info('CONTRACTS_VERSION:', process.env.CONTRACTS_VERSION) console.info('LIGHTWEIGHT_CLIENT:', process.env.LIGHTWEIGHT_CLIENT) @@ -150,7 +150,7 @@ async function startApp () { new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('Timed out setting up service worker')) - }, 8e3) + }, 16e3) })] ).catch(e => { console.error('[main] Error setting up service worker', e) @@ -369,6 +369,7 @@ async function startApp () { }) }).finally(() => { // Wait for SW to be ready + console.debug('[app] Waiting for SW to be ready') navigator.serviceWorker.ready.then(() => { const onready = () => { this.ephemeral.ready = true diff --git a/frontend/model/chatroom/vuexModule.js b/frontend/model/chatroom/vuexModule.js index 90c1da2d86..79a6c011c1 100644 --- a/frontend/model/chatroom/vuexModule.js +++ b/frontend/model/chatroom/vuexModule.js @@ -1,9 +1,10 @@ 'use strict' import sbp from '@sbp/sbp' -import { Vue } from '@common/common.js' import { merge, cloneDeep, union } from '@model/contracts/shared/giLodash.js' import { MESSAGE_NOTIFY_SETTINGS, CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' +import Vue from 'vue' + const defaultState = { currentChatRoomIDs: {}, // { [groupId]: currentChatRoomId } pendingChatRoomIDs: {}, // { [groupId]: currentChatRoomId } diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index 60bd283fc5..9d946d7e8d 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -52,7 +52,7 @@ function createNotificationData ( } async function messageReceivePostEffect ({ - contractID, messageHash, height, text, + contractID, messageHash, height, datetime, text, isDMOrMention, messageType, memberID, chatRoomName }: { contractID: string, @@ -64,6 +64,10 @@ async function messageReceivePostEffect ({ memberID: string, chatRoomName: string }): Promise { + if (await sbp('chelonia/contract/isSyncing', contractID)) { + return + } + // TODO: This can't be a root getter when running in a SW const rootGetters = await sbp('state/vuex/getters') const isGroupDM = rootGetters.isGroupDirectMessage(contractID) @@ -73,10 +77,6 @@ async function messageReceivePostEffect ({ sbp('gi.actions/identity/kv/addChatRoomUnreadMessage', { contractID, messageHash, createdHeight: height }) } - if (sbp('chelonia/contract/isSyncing', contractID)) { - return - } - let title = `# ${chatRoomName}` let icon if (isGroupDM) { @@ -502,7 +502,7 @@ sbp('chelonia/defineContract', { const rootState = sbp('state/vuex/state') const me = rootState.loggedIn.identityContractID - if (rootState.chatroom.chatRoomScrollPosition[contractID] === data.hash) { + if (rootState.chatroom?.chatRoomScrollPosition[contractID] === data.hash) { sbp('state/vuex/commit', 'setChatRoomScrollPosition', { chatRoomID: contractID, messageHash: null }) diff --git a/frontend/model/contracts/shared/voting/proposals.js b/frontend/model/contracts/shared/voting/proposals.js index 860e6dc828..d6601549cf 100644 --- a/frontend/model/contracts/shared/voting/proposals.js +++ b/frontend/model/contracts/shared/voting/proposals.js @@ -1,7 +1,6 @@ 'use strict' import sbp from '@sbp/sbp' -import { Vue } from '@common/common.js' import { objectOf, literalOf, unionOf, number } from '~/frontend/model/contracts/misc/flowTyper.js' import { DAYS_MILLIS } from '../time.js' import rules, { ruleType, VOTE_AGAINST, VOTE_FOR, RULE_PERCENTAGE, RULE_DISAGREEMENT } from './rules.js' @@ -27,7 +26,7 @@ export function notifyAndArchiveProposal ({ state, proposalHash, proposal, contr meta: Object, height: number }) { - Vue.delete(state.proposals, proposalHash) + delete state.proposals[proposalHash] // NOTE: we can not make notification for the proposal closal // in the /proposalVote/sideEffect @@ -41,6 +40,7 @@ export function notifyAndArchiveProposal ({ state, proposalHash, proposal, contr ) } +// TODO: This should be moved to a different file, since it's not used in contracts export function buildInvitationUrl (groupId: string, groupName: string, inviteSecret: string, creatorID?: string): string { const rootGetters = sbp('state/vuex/getters') const creatorUsername = creatorID && rootGetters.usernameFromID(creatorID) diff --git a/frontend/model/notifications/periodicNotifications.js b/frontend/model/notifications/periodicNotifications.js index b702acdb24..623b36f5a2 100644 --- a/frontend/model/notifications/periodicNotifications.js +++ b/frontend/model/notifications/periodicNotifications.js @@ -1,9 +1,9 @@ 'use strict' import sbp from '@sbp/sbp' -import { Vue } from '@common/common.js' +import Vue from 'vue' // $FlowFixMe -import { objectOf, string, isFunction } from '@model/contracts/misc/flowTyper.js' +import { isFunction, objectOf, string } from '@model/contracts/misc/flowTyper.js' import { MINS_MILLIS } from '@model/contracts/shared/time.js' export const PERIODIC_NOTIFICATION_TYPE = { diff --git a/frontend/model/notifications/vuexModule.js b/frontend/model/notifications/vuexModule.js index 6f9e411776..069812dde4 100644 --- a/frontend/model/notifications/vuexModule.js +++ b/frontend/model/notifications/vuexModule.js @@ -1,6 +1,6 @@ 'use strict' -import { Vue } from '@common/common.js' +import Vue from 'vue' import { cloneDeep } from '~/frontend/model/contracts/shared/giLodash.js' import * as keys from './mutationKeys.js' import './selectors.js' diff --git a/frontend/model/state.js b/frontend/model/state.js index d3891cbbf0..46eac4097c 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -4,9 +4,10 @@ // state) per: http://vuex.vuejs.org/en/intro.html import sbp from '@sbp/sbp' -import { Vue, L } from '@common/common.js' +import { L } from '@common/common.js' import { EVENT_HANDLED, CONTRACT_REGISTERED } from '~/shared/domains/chelonia/events.js' import { LOGOUT } from '~/frontend/utils/events.js' +import Vue from 'vue' import Vuex from 'vuex' import { PROFILE_STATUS, INVITE_INITIAL_CREATOR } from '@model/contracts/shared/constants.js' import { PAYMENT_NOT_RECEIVED } from '@model/contracts/shared/payments/index.js' diff --git a/frontend/utils/lazyLoadedView.js b/frontend/utils/lazyLoadedView.js index 72f6618931..3c79c3a9f0 100644 --- a/frontend/utils/lazyLoadedView.js +++ b/frontend/utils/lazyLoadedView.js @@ -1,4 +1,4 @@ -import { Vue } from '@common/common.js' +import Vue from 'vue' import ErrorModal from '@views/containers/loading-error/ErrorModal.vue' import ErrorPage from '@views/containers/loading-error/ErrorPage.vue' import LoadingModal from '@views/containers/loading-error/LoadingModal.vue' diff --git a/frontend/utils/touchInteractions.js b/frontend/utils/touchInteractions.js index 6db84c50ca..955f765a26 100644 --- a/frontend/utils/touchInteractions.js +++ b/frontend/utils/touchInteractions.js @@ -1,4 +1,4 @@ -import { Vue } from '@common/common.js' +import Vue from 'vue' if ('ontouchstart' in window || 'msMaxTouchPoints' in navigator) { import('vue2-touch-events').then(Vue2TouchEvents => Vue.use(Vue2TouchEvents.default)) diff --git a/frontend/views/containers/chatroom/ChatMain.vue b/frontend/views/containers/chatroom/ChatMain.vue index abe8086ab4..79e2199008 100644 --- a/frontend/views/containers/chatroom/ChatMain.vue +++ b/frontend/views/containers/chatroom/ChatMain.vue @@ -123,7 +123,8 @@ import sbp from '@sbp/sbp' import { mapGetters } from 'vuex' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import { Vue, L } from '@common/common.js' +import { L } from '@common/common.js' +import Vue from 'vue' import Avatar from '@components/Avatar.vue' import InfiniteLoading from 'vue-infinite-loading' import Message from './Message.vue' @@ -1137,11 +1138,16 @@ export default ({ this.initializeState(true) this.ephemeral.messagesInitiated = false this.ephemeral.scrolledDistance = 0 - if (sbp('chelonia/contract/isSyncing', toChatRoomId)) { - toIsJoined && sbp('chelonia/queueInvocation', toChatRoomId, () => initAfterSynced(toChatRoomId)) - } else { - this.refreshContent() - } + // Force 'chelonia/contract/isSyncing' to be a Promise + Promise.resolve(sbp('chelonia/contract/isSyncing', toChatRoomId)).then((isSyncing) => { + // If the chatroom has changed since, return + if (this.summary.chatRoomID !== toChatRoomId) return + if (isSyncing) { + toIsJoined && sbp('chelonia/queueInvocation', toChatRoomId, () => initAfterSynced(toChatRoomId)) + } else { + this.refreshContent() + } + }) } else if (toIsJoined && toIsJoined !== fromIsJoined) { sbp('chelonia/queueInvocation', toChatRoomId, () => initAfterSynced(toChatRoomId)) } diff --git a/frontend/views/containers/chatroom/CreatePoll.vue b/frontend/views/containers/chatroom/CreatePoll.vue index cfb6c8dbff..0ed1fb14ac 100644 --- a/frontend/views/containers/chatroom/CreatePoll.vue +++ b/frontend/views/containers/chatroom/CreatePoll.vue @@ -104,12 +104,13 @@