diff options
| author | Shpuld Shpludson <shp@cock.li> | 2019-06-18 20:28:31 +0000 |
|---|---|---|
| committer | Shpuld Shpludson <shp@cock.li> | 2019-06-18 20:28:31 +0000 |
| commit | 0cfa28a7de7cd08820d91c9cba769423d8ce8c89 (patch) | |
| tree | 967230faa328d35db7459ad7126ccf5bf3e2ece1 /src | |
| parent | 69eff65130170c0cd8fffda45b952d3bec49c218 (diff) | |
| parent | 0eed2ccca8a0980c161bb5a52b211c507e0ffef5 (diff) | |
Merge branch 'feature/polls-attempt-2' into 'develop'
Feature/polls attempt 2
See merge request pleroma/pleroma-fe!826
Diffstat (limited to 'src')
33 files changed, 1287 insertions, 191 deletions
diff --git a/src/App.scss b/src/App.scss index 1591f1da..ff7fb5dc 100644 --- a/src/App.scss +++ b/src/App.scss @@ -184,7 +184,43 @@ input, textarea, .select { flex: 1; } - &[type=radio], + &[type=radio] { + display: none; + &:checked + label::before { + box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset; + box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset; + background-color: var(--link, $fallback--link); + } + &:disabled { + &, + & + label, + & + label::before { + opacity: .5; + } + } + + label::before { + display: inline-block; + content: ''; + transition: box-shadow 200ms; + width: 1.1em; + height: 1.1em; + border-radius: 100%; // Radio buttons should always be circle + box-shadow: 0px 0px 2px black inset; + box-shadow: var(--inputShadow); + margin-right: .5em; + background-color: $fallback--fg; + background-color: var(--input, $fallback--fg); + vertical-align: top; + text-align: center; + line-height: 1.1em; + font-size: 1.1em; + box-sizing: border-box; + color: transparent; + overflow: hidden; + box-sizing: border-box; + } + } + &[type=checkbox] { display: none; &:checked + label::before { @@ -230,6 +266,15 @@ option { background-color: var(--bg, $fallback--bg); } +.hide-number-spinner { + -moz-appearance: textfield; + &[type=number]::-webkit-inner-spin-button, + &[type=number]::-webkit-outer-spin-button { + opacity: 0; + display: none; + } +} + i[class*=icon-] { color: $fallback--icon; color: var(--icon, $fallback--icon) diff --git a/src/boot/after_store.js b/src/boot/after_store.js index cbe445ef..3fcbc246 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -215,11 +215,12 @@ const getNodeInfo = async ({ store }) => { if (res.ok) { const data = await res.json() const metadata = data.metadata - const features = metadata.features store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) + store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) + store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats }) diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index fcdc3471..eb785735 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -13,7 +13,7 @@ <style> .media-upload { font-size: 26px; - flex: 1; + min-width: 50px; } .icon-upload { diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index e59e7497..896c6d52 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,6 +1,7 @@ import Status from '../status/status.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' +import Timeago from '../timeago/timeago.vue' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -13,7 +14,10 @@ const Notification = { }, props: [ 'notification' ], components: { - Status, UserAvatar, UserCard + Status, + UserAvatar, + UserCard, + Timeago }, methods: { toggleUserExpanded () { diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 3427b9c5..5ad365ad 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -30,12 +30,12 @@ </div> <div class="timeago" v-if="notification.type === 'follow'"> <span class="faint"> - <timeago :since="notification.created_at" :auto-update="240"></timeago> + <Timeago :time="notification.created_at" :auto-update="240"></Timeago> </span> </div> <div class="timeago" v-else> <router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link"> - <timeago :since="notification.created_at" :auto-update="240"></timeago> + <Timeago :time="notification.created_at" :auto-update="240"></Timeago> </router-link> </div> </span> diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js new file mode 100644 index 00000000..ecacbc35 --- /dev/null +++ b/src/components/poll/poll.js @@ -0,0 +1,107 @@ +import Timeago from '../timeago/timeago.vue' +import { forEach, map } from 'lodash' + +export default { + name: 'Poll', + props: ['poll', 'statusId'], + components: { Timeago }, + data () { + return { + loading: false, + choices: [], + refreshInterval: null + } + }, + created () { + this.refreshInterval = setTimeout(this.refreshPoll, 30 * 1000) + // Initialize choices to booleans and set its length to match options + this.choices = this.poll.options.map(_ => false) + }, + destroyed () { + clearTimeout(this.refreshInterval) + }, + computed: { + expired () { + return Date.now() > Date.parse(this.poll.expires_at) + }, + loggedIn () { + return this.$store.state.users.currentUser + }, + showResults () { + return this.poll.voted || this.expired || !this.loggedIn + }, + totalVotesCount () { + return this.poll.votes_count + }, + expiresAt () { + return Date.parse(this.poll.expires_at).toLocaleString() + }, + containerClass () { + return { + loading: this.loading + } + }, + choiceIndices () { + // Convert array of booleans into an array of indices of the + // items that were 'true', so [true, false, false, true] becomes + // [0, 3]. + return this.choices + .map((entry, index) => entry && index) + .filter(value => typeof value === 'number') + }, + isDisabled () { + const noChoice = this.choiceIndices.length === 0 + return this.loading || noChoice + } + }, + methods: { + refreshPoll () { + if (this.expired) return + this.fetchPoll() + this.refreshInterval = setTimeout(this.refreshPoll, 30 * 1000) + }, + percentageForOption (count) { + return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100) + }, + resultTitle (option) { + return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}` + }, + fetchPoll () { + this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id }) + }, + activateOption (index) { + // forgive me father: doing checking the radio/checkboxes + // in code because of customized input elements need either + // a) an extra element for the actual graphic, or b) use a + // pseudo element for the label. We use b) which mandates + // using "for" and "id" matching which isn't nice when the + // same poll appears multiple times on the site (notifs and + // timeline for example). With code we can make sure it just + // works without altering the pseudo element implementation. + const allElements = this.$el.querySelectorAll('input') + const clickedElement = this.$el.querySelector(`input[value="${index}"]`) + if (this.poll.multiple) { + // Checkboxes, toggle only the clicked one + clickedElement.checked = !clickedElement.checked + } else { + // Radio button, uncheck everything and check the clicked one + forEach(allElements, element => { element.checked = false }) + clickedElement.checked = true + } + this.choices = map(allElements, e => e.checked) + }, + optionId (index) { + return `poll${this.poll.id}-${index}` + }, + vote () { + if (this.choiceIndices.length === 0) return + this.loading = true + this.$store.dispatch( + 'votePoll', + { id: this.statusId, pollId: this.poll.id, choices: this.choiceIndices } + ).then(poll => { + this.loading = false + }) + } + } +}
\ No newline at end of file diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue new file mode 100644 index 00000000..28e9f4a8 --- /dev/null +++ b/src/components/poll/poll.vue @@ -0,0 +1,117 @@ +<template> + <div class="poll" v-bind:class="containerClass"> + <div + class="poll-option" + v-for="(option, index) in poll.options" + :key="index" + > + <div v-if="showResults" :title="resultTitle(option)" class="option-result"> + <div class="option-result-label"> + <span class="result-percentage"> + {{percentageForOption(option.votes_count)}}% + </span> + <span>{{option.title}}</span> + </div> + <div + class="result-fill" + :style="{ 'width': `${percentageForOption(option.votes_count)}%` }" + > + </div> + </div> + <div v-else @click="activateOption(index)"> + <input + v-if="poll.multiple" + type="checkbox" + :disabled="loading" + :value="index" + > + <input + v-else + type="radio" + :disabled="loading" + :value="index" + > + <label> + {{option.title}} + </label> + </div> + </div> + <div class="footer faint"> + <button + v-if="!showResults" + class="btn btn-default poll-vote-button" + type="button" + @click="vote" + :disabled="isDisabled" + > + {{$t('polls.vote')}} + </button> + <div class="total"> + {{totalVotesCount}} {{ $t("polls.votes") }} ยท + </div> + <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'"> + <Timeago :time="this.poll.expires_at" :auto-update="60" :now-threshold="0" /> + </i18n> + </div> + </div> +</template> + +<script src="./poll.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.poll { + .votes { + display: flex; + flex-direction: column; + margin: 0 0 0.5em; + } + .poll-option { + margin: 0.5em 0; + height: 1.5em; + } + .option-result { + height: 100%; + display: flex; + flex-direction: row; + position: relative; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + .option-result-label { + display: flex; + align-items: center; + padding: 0.1em 0.25em; + z-index: 1; + } + .result-percentage { + width: 3.5em; + } + .result-fill { + height: 100%; + position: absolute; + background-color: $fallback--lightBg; + background-color: var(--linkBg, $fallback--lightBg); + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + top: 0; + left: 0; + transition: width 0.5s; + } + input { + width: 3.5em; + } + .footer { + display: flex; + align-items: center; + } + &.loading * { + cursor: progress; + } + .poll-vote-button { + padding: 0 0.5em; + margin-right: 0.5em; + } +} +</style> diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js new file mode 100644 index 00000000..c0c1ccf7 --- /dev/null +++ b/src/components/poll/poll_form.js @@ -0,0 +1,121 @@ +import * as DateUtils from 'src/services/date_utils/date_utils.js' +import { uniq } from 'lodash' + +export default { + name: 'PollForm', + props: ['visible'], + data: () => ({ + pollType: 'single', + options: ['', ''], + expiryAmount: 10, + expiryUnit: 'minutes' + }), + computed: { + pollLimits () { + return this.$store.state.instance.pollLimits + }, + maxOptions () { + return this.pollLimits.max_options + }, + maxLength () { + return this.pollLimits.max_option_chars + }, + expiryUnits () { + const allUnits = ['minutes', 'hours', 'days'] + const expiry = this.convertExpiryFromUnit + return allUnits.filter( + unit => this.pollLimits.max_expiration >= expiry(unit, 1) + ) + }, + minExpirationInCurrentUnit () { + return Math.ceil( + this.convertExpiryToUnit( + this.expiryUnit, + this.pollLimits.min_expiration + ) + ) + }, + maxExpirationInCurrentUnit () { + return Math.floor( + this.convertExpiryToUnit( + this.expiryUnit, + this.pollLimits.max_expiration + ) + ) + } + }, + methods: { + clear () { + this.pollType = 'single' + this.options = ['', ''] + this.expiryAmount = 10 + this.expiryUnit = 'minutes' + }, + nextOption (index) { + const element = this.$el.querySelector(`#poll-${index + 1}`) + if (element) { + element.focus() + } else { + // Try adding an option and try focusing on it + const addedOption = this.addOption() + if (addedOption) { + this.$nextTick(function () { + this.nextOption(index) + }) + } + } + }, + addOption () { + if (this.options.length < this.maxOptions) { + this.options.push('') + return true + } + return false + }, + deleteOption (index, event) { + if (this.options.length > 2) { + this.options.splice(index, 1) + } + }, + convertExpiryToUnit (unit, amount) { + // Note: we want seconds and not milliseconds + switch (unit) { + case 'minutes': return (1000 * amount) / DateUtils.MINUTE + case 'hours': return (1000 * amount) / DateUtils.HOUR + case 'days': return (1000 * amount) / DateUtils.DAY + } + }, + convertExpiryFromUnit (unit, amount) { + // Note: we want seconds and not milliseconds + switch (unit) { + case 'minutes': return 0.001 * amount * DateUtils.MINUTE + case 'hours': return 0.001 * amount * DateUtils.HOUR + case 'days': return 0.001 * amount * DateUtils.DAY + } + }, + expiryAmountChange () { + this.expiryAmount = + Math.max(this.minExpirationInCurrentUnit, this.expiryAmount) + this.expiryAmount = + Math.min(this.maxExpirationInCurrentUnit, this.expiryAmount) + this.updatePollToParent() + }, + updatePollToParent () { + const expiresIn = this.convertExpiryFromUnit( + this.expiryUnit, + this.expiryAmount + ) + + const options = uniq(this.options.filter(option => option !== '')) + if (options.length < 2) { + this.$emit('update-poll', { error: this.$t('polls.not_enough_options') }) + return + } + this.$emit('update-poll', { + options, + multiple: this.pollType === 'multiple', + expiresIn + }) + } + } +} diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue new file mode 100644 index 00000000..0bc6006d --- /dev/null +++ b/src/components/poll/poll_form.vue @@ -0,0 +1,133 @@ +<template> + <div class="poll-form" v-if="visible"> + <div class="poll-option" v-for="(option, index) in options" :key="index"> + <div class="input-container"> + <input + class="poll-option-input" + type="text" + :placeholder="$t('polls.option')" + :maxlength="maxLength" + :id="`poll-${index}`" + v-model="options[index]" + @change="updatePollToParent" + @keydown.enter.stop.prevent="nextOption(index)" + > + </div> + <div class="icon-container" v-if="options.length > 2"> + <i class="icon-cancel" @click="deleteOption(index)"></i> + </div> + </div> + <a + v-if="options.length < maxOptions" + class="add-option faint" + @click="addOption" + > + <i class="icon-plus" /> + {{ $t("polls.add_option") }} + </a> + <div class="poll-type-expiry"> + <div class="poll-type" :title="$t('polls.type')"> + <label for="poll-type-selector" class="select"> + <select class="select" v-model="pollType" @change="updatePollToParent"> + <option value="single">{{$t('polls.single_choice')}}</option> + <option value="multiple">{{$t('polls.multiple_choices')}}</option> + </select> + <i class="icon-down-open"/> + </label> + </div> + <div class="poll-expiry" :title="$t('polls.expiry')"> + <input + type="number" + class="expiry-amount hide-number-spinner" + :min="minExpirationInCurrentUnit" + :max="maxExpirationInCurrentUnit" + v-model="expiryAmount" + @change="expiryAmountChange" + > + <label class="expiry-unit select"> + <select + v-model="expiryUnit" + @change="expiryAmountChange" + > + <option v-for="unit in expiryUnits" :value="unit"> + {{ $t(`time.${unit}_short`, ['']) }} + </option> + </select> + <i class="icon-down-open"/> + </label> + </div> + </div> + </div> +</template> + +<script src="./poll_form.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.poll-form { + display: flex; + flex-direction: column; + padding: 0 0.5em 0.5em; + + .add-option { + align-self: flex-start; + padding-top: 0.25em; + cursor: pointer; + } + + .poll-option { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 0.25em; + } + + .input-container { + width: 100%; + input { + // Hack: dodge the floating X icon + padding-right: 2.5em; + width: 100%; + } + } + + .icon-container { + // Hack: Move the icon over the input box + width: 2em; + margin-left: -2em; + z-index: 1; + } + + .poll-type-expiry { + margin-top: 0.5em; + display: flex; + width: 100%; + } + + .poll-type { + margin-right: 0.75em; + flex: 1 1 60%; + .select { + border: none; + box-shadow: none; + background-color: transparent; + } + } + + .poll-expiry { + display: flex; + + .expiry-amount { + width: 3em; + text-align: right; + } + + .expiry-unit { + border: none; + box-shadow: none; + background-color: transparent; + } + } +} +</style> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index a7874acd..5dbb1c9d 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -2,6 +2,7 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji-input/emoji-input.vue' +import PollForm from '../poll/poll_form.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import { reject, map, uniqBy } from 'lodash' import suggestor from '../emoji-input/suggestor.js' @@ -31,8 +32,9 @@ const PostStatusForm = { ], components: { MediaUpload, - ScopeSelector, - EmojiInput + EmojiInput, + PollForm, + ScopeSelector }, mounted () { this.resize(this.$refs.textarea) @@ -75,10 +77,12 @@ const PostStatusForm = { status: statusText, nsfw: false, files: [], + poll: {}, visibility: scope, contentType }, - caret: 0 + caret: 0, + pollFormVisible: false } }, computed: { @@ -153,8 +157,17 @@ const PostStatusForm = { safeDMEnabled () { return this.$store.state.instance.safeDM }, + pollsAvailable () { + return this.$store.state.instance.pollsAvailable && + this.$store.state.instance.pollLimits.max_options >= 2 + }, hideScopeNotice () { return this.$store.state.config.hideScopeNotice + }, + pollContentError () { + return this.pollFormVisible && + this.newStatus.poll && + this.newStatus.poll.error } }, methods: { @@ -171,6 +184,12 @@ const PostStatusForm = { } } + const poll = this.pollFormVisible ? this.newStatus.poll : {} + if (this.pollContentError) { + this.error = this.pollContentError + return + } + this.posting = true statusPoster.postStatus({ status: newStatus.status, @@ -180,7 +199,8 @@ const PostStatusForm = { media: newStatus.files, store: this.$store, inReplyToStatusId: this.replyTo, - contentType: newStatus.contentType + contentType: newStatus.contentType, + poll }).then((data) => { if (!data.error) { this.newStatus = { @@ -188,9 +208,12 @@ const PostStatusForm = { spoilerText: '', files: [], visibility: newStatus.visibility, - contentType: newStatus.contentType + contentType: newStatus.contentType, + poll: {} } + this.pollFormVisible = false this.$refs.mediaUpload.clearFile() + this.clearPollForm() this.$emit('posted') let el = this.$el.querySelector('textarea') el.style.height = 'auto' @@ -261,6 +284,17 @@ const PostStatusForm = { changeVis (visibility) { this.newStatus.visibility = visibility }, + togglePollForm () { + this.pollFormVisible = !this.pollFormVisible + }, + setPoll (poll) { + this.newStatus.poll = poll + }, + clearPollForm () { + if (this.$refs.pollForm) { + this.$refs.pollForm.clear() + } + }, dismissScopeNotice () { this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 9929aab0..fbeaf39b 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -1,6 +1,6 @@ <template> <div class="post-status-form"> - <form @submit.prevent="postStatus(newStatus)"> + <form @submit.prevent="postStatus(newStatus)" autocomplete="off"> <div class="form-group" > <i18n v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" @@ -91,37 +91,52 @@ :onScopeChange="changeVis"/> </div> </div> - <div class='form-bottom'> + <poll-form + ref="pollForm" + v-if="pollsAvailable" + :visible="pollFormVisible" + @update-poll="setPoll" + /> + <div class='form-bottom'> + <div class='form-bottom-left'> <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> - - <p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p> - <p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> - - <button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button> - <button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button> - <button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button> - </div> - <div class='alert error' v-if="error"> - Error: {{ error }} - <i class="button-icon icon-cancel" @click="clearError"></i> - </div> - <div class="attachments"> - <div class="media-upload-wrapper" v-for="file in newStatus.files"> - <i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i> - <div class="media-upload-container attachment"> - <img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img> - <video v-if="type(file) === 'video'" :src="file.url" controls></video> - <audio v-if="type(file) === 'audio'" :src="file.url" controls></audio> - <a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a> - </div> + <div v-if="pollsAvailable" class="poll-icon"> + <i + :title="$t('polls.add_poll')" + @click="togglePollForm" + class="icon-chart-bar btn btn-default" + :class="pollFormVisible && 'selected'" + /> </div> </div> - <div class="upload_settings" v-if="newStatus.files.length > 0"> - <input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw"> - <label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label> + <p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p> + <p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> + + <button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button> + <button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button> + <button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button> + </div> + <div class='alert error' v-if="error"> + Error: {{ error }} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + <div class="attachments"> + <div class="media-upload-wrapper" v-for="file in newStatus.files"> + <i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i> + <div class="media-upload-container attachment"> + <img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img> + <video v-if="type(file) === 'video'" :src="file.url" controls></video> + <audio v-if="type(file) === 'audio'" :src="file.url" controls></audio> + <a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a> + </div> </div> - </form> - </div> + </div> + <div class="upload_settings" v-if="newStatus.files.length > 0"> + <input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw"> + <label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label> + </div> + </form> +</div> </template> <script src="./post_status_form.js"></script> @@ -172,6 +187,11 @@ } } + .form-bottom-left { + display: flex; + flex: 1; + } + .text-format { .only-format { color: $fallback--faint; @@ -179,6 +199,20 @@ } } + .poll-icon { + font-size: 26px; + flex: 1; + + .selected { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + } + + .icon-chart-bar { + cursor: pointer; + } + .error { text-align: center; @@ -240,7 +274,6 @@ } } - .btn { cursor: pointer; } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 7067c508..528c13cc 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -302,4 +302,4 @@ </template> <script src="./settings.js"> -</script> +</script>
\ No newline at end of file diff --git a/src/components/status/status.js b/src/components/status/status.js index ea4c2b9d..d2452935 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -1,6 +1,7 @@ import Attachment from '../attachment/attachment.vue' import FavoriteButton from '../favorite_button/favorite_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' +import Poll from '../poll/poll.vue' import ExtraButtons from '../extra_buttons/extra_buttons.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCard from '../user_card/user_card.vue' @@ -8,6 +9,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import Gallery from '../gallery/gallery.vue' import LinkPreview from '../link-preview/link-preview.vue' import AvatarList from '../avatar_list/avatar_list.vue' +import Timeago from '../timeago/timeago.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' @@ -216,8 +218,8 @@ const Status = { if (!this.status.summary) return '' const decodedSummary = unescape(this.status.summary) const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined' - ? this.$store.state.instance.subjectLineBehavior - : this.$store.state.config.subjectLineBehavior + ? this.$store.state.instance.subjectLineBehavior + : this.$store.state.config.subjectLineBehavior const startsWithRe = decodedSummary.match(/^re[: ]/i) if (behavior !== 'noop' && startsWithRe || behavior === 'masto') { return decodedSummary @@ -285,11 +287,13 @@ const Status = { RetweetButton, ExtraButtons, PostStatusForm, + Poll, UserCard, UserAvatar, Gallery, LinkPreview, - AvatarList + AvatarList, + Timeago }, methods: { visibilityIcon (visibility) { @@ -377,7 +381,7 @@ const Status = { this.preview = find(statuses, { 'id': targetId }) // or if we have to fetch it if (!this.preview) { - this.$store.state.api.backendInteractor.fetchStatus({id}).then((status) => { + this.$store.state.api.backendInteractor.fetchStatus({ id }).then((status) => { this.preview = status }) } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index e1dd81ac..58402f7e 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -52,7 +52,7 @@ <span class="heading-right"> <router-link class="timeago faint-link" :to="{ name: 'conversation', params: { id: status.id } }"> - <timeago :since="status.created_at" :auto-update="60"></timeago> + <Timeago :time="status.created_at" :auto-update="60"></Timeago> </router-link> <div class="button-icon visibility-icon" v-if="status.visibility"> <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i> @@ -123,6 +123,10 @@ <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">{{$t("general.show_less")}}</a> </div> + <div v-if="status.poll && status.poll.options"> + <poll :poll="status.poll" :status-id="status.id" /> + </div> + <div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body"> <attachment class="non-gallery" diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue new file mode 100644 index 00000000..3f185a2d --- /dev/null +++ b/src/components/timeago/timeago.vue @@ -0,0 +1,48 @@ +<template> + <time :datetime="time" :title="localeDateString"> + {{ $t(relativeTime.key, [relativeTime.num]) }} + </time> +</template> + +<script> +import * as DateUtils from 'src/services/date_utils/date_utils.js' + +export default { + name: 'Timeago', + props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'], + data () { + return { + relativeTime: { key: 'time.now', num: 0 }, + interval: null + } + }, + created () { + this.refreshRelativeTimeObject() + }, + destroyed () { + clearTimeout(this.interval) + }, + computed: { + localeDateString () { + return typeof this.time === 'string' + ? new Date(Date.parse(this.time)).toLocaleString() + : this.time.toLocaleString() + } + }, + methods: { + refreshRelativeTimeObject () { + const nowThreshold = typeof this.nowThreshold === 'number' ? this.nowThreshold : 1 + this.relativeTime = this.longFormat + ? DateUtils.relativeTime(this.time, nowThreshold) + : DateUtils.relativeTimeShort(this.time, nowThreshold) + + if (this.autoUpdate) { + this.interval = setTimeout( + this.refreshRelativeTimeObject, + 1000 * this.autoUpdate + ) + } + } + } +} +</script>
\ No newline at end of file diff --git a/src/i18n/ca.json b/src/i18n/ca.json index 8fa3a88b..42d7745c 100644 --- a/src/i18n/ca.json +++ b/src/i18n/ca.json @@ -168,6 +168,40 @@ "true": "sรญ" } }, + "time": { + "day": "{0} dia", + "days": "{0} dies", + "day_short": "{0} dia", + "days_short": "{0} dies", + "hour": "{0} hour", + "hours": "{0} hours", + "hour_short": "{0}h", + "hours_short": "{0}h", + "in_future": "in {0}", + "in_past": "fa {0}", + "minute": "{0} minute", + "minutes": "{0} minutes", + "minute_short": "{0}min", + "minutes_short": "{0}min", + "month": "{0} mes", + "months": "{0} mesos", + "month_short": "{0} mes", + "months_short": "{0} mesos", + "now": "ara mateix", + "now_short": "ara mateix", + "second": "{0} second", + "seconds": "{0} seconds", + "second_short": "{0}s", + "seconds_short": "{0}s", + "week": "{0} setm.", + "weeks": "{0} setm.", + "week_short": "{0} setm.", + "weeks_short": "{0} setm.", + "year": "{0} any", + "years": "{0} anys", + "year_short": "{0} any", + "years_short": "{0} anys" + }, "timeline": { "collapse": "Replega", "conversation": "Conversa", diff --git a/src/i18n/cs.json b/src/i18n/cs.json index 5f2f2b71..42e75567 100644 --- a/src/i18n/cs.json +++ b/src/i18n/cs.json @@ -350,6 +350,40 @@ } } }, + "time": { + "day": "{0} day", + "days": "{0} days", + "day_short": "{0}d", + "days_short": "{0}d", + "hour": "{0} hour", + "hours": "{0} hours", + "hour_short": "{0}h", + "hours_short": "{0}h", + "in_future": "in {0}", + "in_past": "{0} ago", + "minute": "{0} minute", + "minutes": "{0} minutes", + "minute_short": "{0}min", + "minutes_short": "{0}min", + "month": "{0} mฤs", + "months": "{0} mฤs", + "month_short": "{0} mฤs", + "months_short": "{0} mฤs", + "now": "teฤ", + "now_short": "teฤ", + "second": "{0} second", + "seconds": "{0} seconds", + "second_short": "{0}s", + "seconds_short": "{0}s", + "week": "{0} tรฝd", + "weeks": "{0} tรฝd", + "week_short": "{0} tรฝd", + "weeks_short": "{0} tรฝd", + "year": "{0} r", + "years": "{0} l", + "year_short": "{0}r", + "years_short": "{0}l" + }, "timeline": { "collapse": "Zabalit", "conversation": "Konverzace", diff --git a/src/i18n/en.json b/src/i18n/en.json index a29f394b..dd34a95d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -91,6 +91,20 @@ "repeated_you": "repeated your status", "no_more_notifications": "No more notifications" }, + "polls": { + "add_poll": "Add Poll", + "add_option": "Add Option", + "option": "Option", + "votes": "votes", + "vote": "Vote", + "type": "Poll type", + "single_choice": "Single choice", + "multiple_choices": "Multiple choices", + "expiry": "Poll age", + "expires_in": "Poll ends in {0}", + "expired": "Poll ended {0} ago", + "not_enough_options": "Too few unique options in poll" + }, "interactions": { "favs_repeats": "Repeats and Favorites", "follows": "New follows", @@ -435,6 +449,40 @@ "frontend_version": "Frontend Version" } }, + "time": { + "day": "{0} day", + "days": "{0} days", + "day_short": "{0}d", + "days_short": "{0}d", + "hour": "{0} hour", + "hours": "{0} hours", + "hour_short": "{0}h", + "hours_short": "{0}h", + "in_future": "in {0}", + "in_past": "{0} ago", + "minute": "{0} minute", + "minutes": "{0} minutes", + "minute_short": "{0}min", + "minutes_short": "{0}min", + "month": "{0} month", + "months": "{0} months", + "month_short": "{0}mo", + "months_short": "{0}mo", + "now": "just now", + "now_short": "now", + "second": "{0} second", + "seconds": "{0} seconds", + "second_short": "{0}s", + "seconds_short": "{0}s", + "week": "{0} week", + "weeks": "{0} weeks", + "week_short": "{0}w", + "weeks_short": "{0}w", + "year": "{0} year", + "years": "{0} years", + "year_short": "{0}y", + "years_short": "{0}y" + }, "timeline": { "collapse": "Collapse", "conversation": "Conversation", diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 62cbecb8..f4179495 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -36,6 +36,7 @@ "chat": "Paikallinen Chat", "friend_requests": "Seurauspyynnรถt", "mentions": "Maininnat", + "interactions": "Interaktiot", "dms": "Yksityisviestit", "public_tl": "Julkinen Aikajana", "timeline": "Aikajana", @@ -54,6 +55,25 @@ "repeated_you": "toisti viestisi", "no_more_notifications": "Ei enempรครค ilmoituksia" }, + "polls": { + "add_poll": "Lisรครค รครคnestys", + "add_option": "Lisรครค vaihtoehto", + "option": "Vaihtoehto", + "votes": "รครคntรค", + "vote": "รรคnestรค", + "type": "รรคnestyksen tyyppi", + "single_choice": "Yksi valinta", + "multiple_choices": "Monivalinta", + "expiry": "รรคnestyksen kesto", + "expires_in": "Pรครคttyy {0} pรครคstรค", + "expired": "Pรครคttyi {0} sitten", + "not_enough_option": "Liian vรคhรคn uniikkeja vaihtoehtoja รครคnestyksessรค" + }, + "interactions": { + "favs_repeats": "Toistot ja tykkรคykset", + "follows": "Uudet seuraukset", + "load_older": "Lataa vanhempia interaktioita" + }, "post_status": { "new_status": "Uusi viesti", "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nรคhdรคksesi 'vain-seuraajille' -viestisi", @@ -210,6 +230,40 @@ "true": "pรครคllรค" } }, + "time": { + "day": "{0} pรคivรค", + "days": "{0} pรคivรครค", + "day_short": "{0}pv", + "days_short": "{0}pv", + "hour": "{0} tunti", + "hours": "{0} tuntia", + "hour_short": "{0}t", + "hours_short": "{0}t", + "in_future": "{0} tulevaisuudessa", + "in_past": "{0} sitten", + "minute": "{0} minuutti", + "minutes": "{0} minuuttia", + "minute_short": "{0}min", + "minutes_short": "{0}min", + "month": "{0} kuukausi", + "months": "{0} kuukautta", + "month_short": "{0}kk", + "months_short": "{0}kk", + "now": "nyt", + "now_short": "juuri nyt", + "second": "{0} sekunti", + "seconds": "{0} sekuntia", + "second_short": "{0}s", + "seconds_short": "{0}s", + "week": "{0} viikko", + "weeks": "{0} viikkoa", + "week_short": "{0}vk", + "weeks_short": "{0}vk", + "year": "{0} vuosi", + "years": "{0} vuotta", + "year_short": "{0}v", + "years_short": "{0}v" + }, "timeline": { "collapse": "Sulje", "conversation": "Keskustelu", @@ -264,9 +318,9 @@ }, "upload":{ "error": { - "base": "Lataus epรคonnistui.", - "file_too_big": "Tiedosto liian suuri [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", - "default": "Yritรค uudestaan myรถhemmin" + "base": "Lataus epรคonnistui.", + "file_too_big": "Tiedosto liian suuri [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Yritรค uudestaan myรถhemmin" }, "file_size_units": { "B": "tavua", diff --git a/src/i18n/ga.json b/src/i18n/ga.json index 31250876..7a10ba40 100644 --- a/src/i18n/ga.json +++ b/src/i18n/ga.json @@ -170,6 +170,40 @@ "true": "tรก" } }, + "time": { + "day": "{0} lรก", + "days": "{0} lรก", + "day_short": "{0}l", + "days_short": "{0}l", + "hour": "{0} uair", + "hours": "{0} uair", + "hour_short": "{0}u", + "hours_short": "{0}u", + "in_future": "in {0}", + "in_past": "{0} ago", + "minute": "{0} nรณimeรกd", + "minutes": "{0} nรณimeรกd", + "minute_short": "{0}n", + "minutes_short": "{0}n", + "month": "{0} mรญ", + "months": "{0} mรญ", + "month_short": "{0}m", + "months_short": "{0}m", + "now": "Anois", + "now_short": "Anois", + "second": "{0} s", + "seconds": "{0} s", + "second_short": "{0}s", + "seconds_short": "{0}s", + "week": "{0} seachtain", + "weeks": "{0} seachtaine", + "week_short": "{0}se", + "weeks_short": "{0}se", + "year": "{0} bliainta", + "years": "{0} bliainta", + "year_short": "{0}b", + "years_short": "{0}b" + }, "timeline": { "collapse": "Folaigh", "conversation": "Cรณmhra", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 87ab9dfd..559bb4c9 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -402,6 +402,40 @@ "frontend_version": "ใใญใณใใจใณใใฎใใผใธใงใณ" } }, + "time": { + "day": "{0}ๆฅ", + "days": "{0}ๆฅ", + "day_short": "{0}ๆฅ", + "days_short": "{0}ๆฅ", + "hour": "{0}ๆ้", + "hours": "{0}ๆ้", + "hour_short": "{0}ๆ้", + "hours_short": "{0}ๆ้", + "in_future": "{0}ใง", + "in_past": "{0}ๅ", + "minute": "{0}ๅ", + "minutes": "{0}ๅ", + "minute_short": "{0}ๅ", + "minutes_short": "{0}ๅ", + "month": "{0}ใถๆๅ", + "months": "{0}ใถๆๅ", + "month_short": "{0}ใถๆๅ", + "months_short": "{0}ใถๆๅ", + "now": "ใใฃใไป", + "now_short": "ใใฃใไป", + "second": "{0}็ง", + "seconds": "{0}็ง", + "second_short": "{0}็ง", + "seconds_short": "{0}็ง", + "week": "{0}้ฑ้", + "weeks": "{0}้ฑ้", + "week_short": "{0}้ฑ้", + "weeks_short": "{0}้ฑ้", + "year": "{0}ๅนด", + "years": "{0}ๅนด", + "year_short": "{0}ๅนด", + "years_short": "{0}ๅนด" + }, "timeline": { "collapse": "ใใใ", "conversation": "ในใฌใใ", diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json index 9036baf5..345e1f1c 100644 --- a/src/i18n/ja_pedantic.json +++ b/src/i18n/ja_pedantic.json @@ -402,6 +402,40 @@ "frontend_version": "ใใญใณใใจใณใใฎใใผใธใงใณ" } }, + "time": { + "day": "{0}ๆฅ", + "days": "{0}ๆฅ", + "day_short": "{0}ๆฅ", + "days_short": "{0}ๆฅ", + "hour": "{0}ๆ้", + "hours": "{0}ๆ้", + "hour_short": "{0}ๆ้", + "hours_short": "{0}ๆ้", + "in_future": "{0}ใง", + "in_past": "{0}ๅ", + "minute": "{0}ๅ", + "minutes": "{0}ๅ", + "minute_short": "{0}ๅ", + "minutes_short": "{0}ๅ", + "month": "{0}ใถๆๅ", + "months": "{0}ใถๆๅ", + "month_short": "{0}ใถๆๅ", + "months_short": "{0}ใถๆๅ", + "now": "ใใฃใไป", + "now_short": "ใใฃใไป", + "second": "{0}็ง", + "seconds": "{0}็ง", + "second_short": "{0}็ง", + "seconds_short": "{0}็ง", + "week": "{0}้ฑ้", + "weeks": "{0}้ฑ้", + "week_short": "{0}้ฑ้", + "weeks_short": "{0}้ฑ้", + "year": "{0}ๅนด", + "years": "{0}ๅนด", + "year_short": "{0}ๅนด", + "years_short": "{0}ๅนด" + }, "timeline": { "collapse": "ใใใ", "conversation": "ในใฌใใ", diff --git a/src/i18n/oc.json b/src/i18n/oc.json index 5f8d153f..ec7f5740 100644 --- a/src/i18n/oc.json +++ b/src/i18n/oc.json @@ -381,6 +381,40 @@ "frontend_version": "Version Frontend" } }, + "time": { + "day": "{0} jorn", + "days": "{0} jorns", + "day_short": "{0} jorn", + "days_short": "{0} jorns", + "hour": "{0} hour", + "hours": "{0} hours", + "hour_short": "{0}h", + "hours_short": "{0}h", + "in_future": "in {0}", + "in_past": "fa {0}", + "minute": "{0} minute", + "minutes": "{0} minutes", + "minute_short": "{0}min", + "minutes_short": "{0}min", + "month": "{0} mes", + "months": "{0} meses", + "month_short": "{0} mes", + "months_short": "{0} meses", + "now": "ara meteis", + "now_short": "ara meteis", + "second": "{0} second", + "seconds": "{0} seconds", + "second_short": "{0}s", + "seconds_short": "{0}s", + "week": "{0} setm.", + "weeks": "{0} setm.", + "week_short": "{0} setm.", + "weeks_short": "{0} setm.", + "year": "{0} an", + "years": "{0} ans", + "year_short": "{0} an", + "years_short": "{0} ans" + }, "timeline": { "collapse": "Tampar", "conversation": "Conversacion", diff --git a/src/main.js b/src/main.js index 5758c7bd..d0f2674b 100644 --- a/src/main.js +++ b/src/main.js @@ -15,7 +15,6 @@ import mediaViewerModule from './modules/media_viewer.js' import oauthTokensModule from './modules/oauth_tokens.js' import reportsModule from './modules/reports.js' -import VueTimeago from 'vue-timeago' import VueI18n from 'vue-i18n' import createPersistedState from './lib/persisted_state.js' @@ -33,14 +32,6 @@ const currentLocale = (window.navigator.language || 'en').split('-')[0] Vue.use(Vuex) Vue.use(VueRouter) -Vue.use(VueTimeago, { - locale: currentLocale === 'cs' ? 'cs' : currentLocale === 'ja' ? 'ja' : 'en', - locales: { - 'cs': require('../static/timeago-cs.json'), - 'en': require('../static/timeago-en.json'), - 'ja': require('../static/timeago-ja.json') - } -}) Vue.use(VueI18n) Vue.use(VueChatScroll) Vue.use(VueClickOutside) diff --git a/src/modules/api.js b/src/modules/api.js index 7ed3edac..d51b31f3 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -59,7 +59,7 @@ const api = { // Set up websocket connection if (!store.state.chatDisabled) { const token = store.state.wsToken - const socket = new Socket('/socket', {params: {token}}) + const socket = new Socket('/socket', { params: { token } }) socket.connect() store.dispatch('initializeChat', socket) } diff --git a/src/modules/instance.js b/src/modules/instance.js index 59beb23c..22addb9b 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -52,7 +52,15 @@ const defaultState = { // Version Information backendVersion: '', - frontendVersion: '' + frontendVersion: '', + + pollsAvailable: false, + pollLimits: { + max_options: 4, + max_option_chars: 255, + min_expiration: 60, + max_expiration: 60 * 60 * 24 + } } const instance = { diff --git a/src/modules/statuses.js b/src/modules/statuses.js index e6ee5447..5f09b8f5 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -494,6 +494,10 @@ export const mutations = { const newStatus = state.allStatusesObject[id] newStatus.favoritedBy = favoritedByUsers.filter(_ => _) newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _) + }, + updateStatusWithPoll (state, { id, poll }) { + const status = state.allStatusesObject[id] + status.poll = poll } } @@ -578,6 +582,18 @@ const statuses = { ]).then(([favoritedByUsers, rebloggedByUsers]) => commit('addFavsAndRepeats', { id, favoritedByUsers, rebloggedByUsers }) ) + }, + votePoll ({ rootState, commit }, { id, pollId, choices }) { + return rootState.api.backendInteractor.vote(pollId, choices).then(poll => { + commit('updateStatusWithPoll', { id, poll }) + return poll + }) + }, + refreshPoll ({ rootState, commit }, { id, pollId }) { + return rootState.api.backendInteractor.fetchPoll(pollId).then(poll => { + commit('updateStatusWithPoll', { id, poll }) + return poll + }) } }, mutations diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index ab1fc0b2..d5fc418d 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,3 +1,8 @@ +import { each, map, concat, last } from 'lodash' +import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' +import 'whatwg-fetch' +import { StatusCodeError } from '../errors/errors' + /* eslint-env browser */ const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json' @@ -52,6 +57,8 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute` const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` const MASTODON_POST_STATUS_URL = '/api/v1/statuses' const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media' +const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes` +const MASTODON_POLL_URL = id => `/api/v1/polls/${id}` const MASTODON_STATUS_FAVORITEDBY_URL = id => `/api/v1/statuses/${id}/favourited_by` const MASTODON_STATUS_REBLOGGEDBY_URL = id => `/api/v1/statuses/${id}/reblogged_by` const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials' @@ -59,11 +66,6 @@ const MASTODON_REPORT_USER_URL = '/api/v1/reports' const MASTODON_PIN_OWN_STATUS = id => `/api/v1/statuses/${id}/pin` const MASTODON_UNPIN_OWN_STATUS = id => `/api/v1/statuses/${id}/unpin` -import { each, map, concat, last } from 'lodash' -import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' -import 'whatwg-fetch' -import { StatusCodeError } from '../errors/errors' - const oldfetch = window.fetch let fetch = (url, options) => { @@ -104,7 +106,7 @@ const promisedRequest = ({ method, url, payload, credentials, headers = {} }) => }) } -const updateNotificationSettings = ({credentials, settings}) => { +const updateNotificationSettings = ({ credentials, settings }) => { const form = new FormData() each(settings, (value, key) => { @@ -115,20 +117,18 @@ const updateNotificationSettings = ({credentials, settings}) => { headers: authHeaders(credentials), method: 'PUT', body: form - }) - .then((data) => data.json()) + }).then((data) => data.json()) } -const updateAvatar = ({credentials, avatar}) => { +const updateAvatar = ({ credentials, avatar }) => { const form = new FormData() form.append('avatar', avatar) return fetch(MASTODON_PROFILE_UPDATE_URL, { headers: authHeaders(credentials), method: 'PATCH', body: form - }) - .then((data) => data.json()) - .then((data) => parseUser(data)) + }).then((data) => data.json()) + .then((data) => parseUser(data)) } const updateBg = ({ credentials, background }) => { @@ -143,26 +143,24 @@ const updateBg = ({ credentials, background }) => { .then((data) => parseUser(data)) } -const updateBanner = ({credentials, banner}) => { +const updateBanner = ({ credentials, banner }) => { const form = new FormData() form.append('header', banner) return fetch(MASTODON_PROFILE_UPDATE_URL, { headers: authHeaders(credentials), method: 'PATCH', body: form - }) - .then((data) => data.json()) - .then((data) => parseUser(data)) + }).then((data) => data.json()) + .then((data) => parseUser(data)) } -const updateProfile = ({credentials, params}) => { +const updateProfile = ({ credentials, params }) => { return promisedRequest({ url: MASTODON_PROFILE_UPDATE_URL, method: 'PATCH', payload: params, credentials - }) - .then((data) => parseUser(data)) + }).then((data) => parseUser(data)) } // Params needed: @@ -212,7 +210,7 @@ const authHeaders = (accessToken) => { } } -const externalProfile = ({profileUrl, credentials}) => { +const externalProfile = ({ profileUrl, credentials }) => { let url = `${EXTERNAL_PROFILE_URL}?profileurl=${profileUrl}` return fetch(url, { headers: authHeaders(credentials), @@ -220,7 +218,7 @@ const externalProfile = ({profileUrl, credentials}) => { }).then((data) => data.json()) } -const followUser = ({id, credentials}) => { +const followUser = ({ id, credentials }) => { let url = MASTODON_FOLLOW_URL(id) return fetch(url, { headers: authHeaders(credentials), @@ -228,7 +226,7 @@ const followUser = ({id, credentials}) => { }).then((data) => data.json()) } -const unfollowUser = ({id, credentials}) => { +const unfollowUser = ({ id, credentials }) => { let url = MASTODON_UNFOLLOW_URL(id) return fetch(url, { headers: authHeaders(credentials), @@ -246,21 +244,21 @@ const unpinOwnStatus = ({ id, credentials }) => { .then((data) => parseStatus(data)) } -const blockUser = ({id, credentials}) => { +const blockUser = ({ id, credentials }) => { return fetch(MASTODON_BLOCK_USER_URL(id), { headers: authHeaders(credentials), method: 'POST' }).then((data) => data.json()) } -const unblockUser = ({id, credentials}) => { +const unblockUser = ({ id, credentials }) => { return fetch(MASTODON_UNBLOCK_USER_URL(id), { headers: authHeaders(credentials), method: 'POST' }).then((data) => data.json()) } -const approveUser = ({id, credentials}) => { +const approveUser = ({ id, credentials }) => { let url = `${APPROVE_USER_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials), @@ -268,7 +266,7 @@ const approveUser = ({id, credentials}) => { }).then((data) => data.json()) } -const denyUser = ({id, credentials}) => { +const denyUser = ({ id, credentials }) => { let url = `${DENY_USER_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials), @@ -276,13 +274,13 @@ const denyUser = ({id, credentials}) => { }).then((data) => data.json()) } -const fetchUser = ({id, credentials}) => { +const fetchUser = ({ id, credentials }) => { let url = `${MASTODON_USER_URL}/${id}` return promisedRequest({ url, credentials }) .then((data) => parseUser(data)) } -const fetchUserRelationship = ({id, credentials}) => { +const fetchUserRelationship = ({ id, credentials }) => { let url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}` return fetch(url, { headers: authHeaders(credentials) }) .then((response) => { @@ -296,7 +294,7 @@ const fetchUserRelationship = ({id, credentials}) => { }) } -const fetchFriends = ({id, maxId, sinceId, limit = 20, credentials}) => { +const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => { let url = MASTODON_FOLLOWING_URL(id) const args = [ maxId && `max_id=${maxId}`, @@ -310,7 +308,7 @@ const fetchFriends = ({id, maxId, sinceId, limit = 20, credentials}) => { .then((data) => data.map(parseUser)) } -const exportFriends = ({id, credentials}) => { +const exportFriends = ({ id, credentials }) => { return new Promise(async (resolve, reject) => { try { let friends = [] @@ -330,7 +328,7 @@ const exportFriends = ({id, credentials}) => { }) } -const fetchFollowers = ({id, maxId, sinceId, limit = 20, credentials}) => { +const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => { let url = MASTODON_FOLLOWERS_URL(id) const args = [ maxId && `max_id=${maxId}`, @@ -344,13 +342,13 @@ const fetchFollowers = ({id, maxId, sinceId, limit = 20, credentials}) => { .then((data) => data.map(parseUser)) } -const fetchFollowRequests = ({credentials}) => { +const fetchFollowRequests = ({ credentials }) => { const url = FOLLOW_REQUESTS_URL return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) } -const fetchConversation = ({id, credentials}) => { +const fetchConversation = ({ id, credentials }) => { let urlContext = MASTODON_STATUS_CONTEXT_URL(id) return fetch(urlContext, { headers: authHeaders(credentials) }) .then((data) => { @@ -360,13 +358,13 @@ const fetchConversation = ({id, credentials}) => { throw new Error('Error fetching timeline', data) }) .then((data) => data.json()) - .then(({ancestors, descendants}) => ({ + .then(({ ancestors, descendants }) => ({ ancestors: ancestors.map(parseStatus), descendants: descendants.map(parseStatus) })) } -const fetchStatus = ({id, credentials}) => { +const fetchStatus = ({ id, credentials }) => { let url = MASTODON_STATUS_URL(id) return fetch(url, { headers: authHeaders(credentials) }) .then((data) => { @@ -379,7 +377,7 @@ const fetchStatus = ({id, credentials}) => { .then((data) => parseStatus(data)) } -const tagUser = ({tag, credentials, ...options}) => { +const tagUser = ({ tag, credentials, ...options }) => { const screenName = options.screen_name const form = { nicknames: [screenName], @@ -396,7 +394,7 @@ const tagUser = ({tag, credentials, ...options}) => { }) } -const untagUser = ({tag, credentials, ...options}) => { +const untagUser = ({ tag, credentials, ...options }) => { const screenName = options.screen_name const body = { nicknames: [screenName], @@ -413,7 +411,7 @@ const untagUser = ({tag, credentials, ...options}) => { }) } -const addRight = ({right, credentials, ...user}) => { +const addRight = ({ right, credentials, ...user }) => { const screenName = user.screen_name return fetch(PERMISSION_GROUP_URL(screenName, right), { @@ -423,7 +421,7 @@ const addRight = ({right, credentials, ...user}) => { }) } -const deleteRight = ({right, credentials, ...user}) => { +const deleteRight = ({ right, credentials, ...user }) => { const screenName = user.screen_name return fetch(PERMISSION_GROUP_URL(screenName, right), { @@ -433,7 +431,7 @@ const deleteRight = ({right, credentials, ...user}) => { }) } -const setActivationStatus = ({status, credentials, ...user}) => { +const setActivationStatus = ({ status, credentials, ...user }) => { const screenName = user.screen_name const body = { status: status @@ -449,7 +447,7 @@ const setActivationStatus = ({status, credentials, ...user}) => { }) } -const deleteUser = ({credentials, ...user}) => { +const deleteUser = ({ credentials, ...user }) => { const screenName = user.screen_name const headers = authHeaders(credentials) @@ -459,7 +457,15 @@ const deleteUser = ({credentials, ...user}) => { }) } -const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false, tag = false, withMuted = false}) => { +const fetchTimeline = ({ + timeline, + credentials, + since = false, + until = false, + userId = false, + tag = false, + withMuted = false +}) => { const timelineUrls = { public: MASTODON_PUBLIC_TIMELINE, friends: MASTODON_USER_HOME_TIMELINE_URL, @@ -558,8 +564,19 @@ const unretweet = ({ id, credentials }) => { .then((data) => parseStatus(data)) } -const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds = [], inReplyToStatusId, contentType}) => { +const postStatus = ({ + credentials, + status, + spoilerText, + visibility, + sensitive, + poll, + mediaIds = [], + inReplyToStatusId, + contentType +}) => { const form = new FormData() + const pollOptions = poll.options || [] form.append('status', status) form.append('source', 'Pleroma FE') @@ -570,6 +587,19 @@ const postStatus = ({credentials, status, spoilerText, visibility, sensitive, me mediaIds.forEach(val => { form.append('media_ids[]', val) }) + if (pollOptions.some(option => option !== '')) { + const normalizedPoll = { + expires_in: poll.expiresIn, + multiple: poll.multiple + } + Object.keys(normalizedPoll).forEach(key => { + form.append(`poll[${key}]`, normalizedPoll[key]) + }) + + pollOptions.forEach(option => { + form.append('poll[options][]', option) + }) + } if (inReplyToStatusId) { form.append('in_reply_to_id', inReplyToStatusId) } @@ -598,7 +628,7 @@ const deleteStatus = ({ id, credentials }) => { }) } -const uploadMedia = ({formData, credentials}) => { +const uploadMedia = ({ formData, credentials }) => { return fetch(MASTODON_MEDIA_UPLOAD_URL, { body: formData, method: 'POST', @@ -608,7 +638,7 @@ const uploadMedia = ({formData, credentials}) => { .then((data) => parseAttachment(data)) } -const importBlocks = ({file, credentials}) => { +const importBlocks = ({ file, credentials }) => { const formData = new FormData() formData.append('list', file) return fetch(BLOCKS_IMPORT_URL, { @@ -619,7 +649,7 @@ const importBlocks = ({file, credentials}) => { .then((response) => response.ok) } -const importFollows = ({file, credentials}) => { +const importFollows = ({ file, credentials }) => { const formData = new FormData() formData.append('list', file) return fetch(FOLLOW_IMPORT_URL, { @@ -630,7 +660,7 @@ const importFollows = ({file, credentials}) => { .then((response) => response.ok) } -const deleteAccount = ({credentials, password}) => { +const deleteAccount = ({ credentials, password }) => { const form = new FormData() form.append('password', password) @@ -643,7 +673,7 @@ const deleteAccount = ({credentials, password}) => { .then((response) => response.json()) } -const changePassword = ({credentials, password, newPassword, newPasswordConfirmation}) => { +const changePassword = ({ credentials, password, newPassword, newPasswordConfirmation }) => { const form = new FormData() form.append('password', password) @@ -658,14 +688,14 @@ const changePassword = ({credentials, password, newPassword, newPasswordConfirma .then((response) => response.json()) } -const settingsMFA = ({credentials}) => { +const settingsMFA = ({ credentials }) => { return fetch(MFA_SETTINGS_URL, { headers: authHeaders(credentials), method: 'GET' }).then((data) => data.json()) } -const mfaDisableOTP = ({credentials, password}) => { +const mfaDisableOTP = ({ credentials, password }) => { const form = new FormData() form.append('password', password) @@ -678,7 +708,7 @@ const mfaDisableOTP = ({credentials, password}) => { .then((response) => response.json()) } -const mfaConfirmOTP = ({credentials, password, token}) => { +const mfaConfirmOTP = ({ credentials, password, token }) => { const form = new FormData() form.append('password', password) @@ -690,38 +720,38 @@ const mfaConfirmOTP = ({credentials, password, token}) => { method: 'POST' }).then((data) => data.json()) } -const mfaSetupOTP = ({credentials}) => { +const mfaSetupOTP = ({ credentials }) => { return fetch(MFA_SETUP_OTP_URL, { headers: authHeaders(credentials), method: 'GET' }).then((data) => data.json()) } -const generateMfaBackupCodes = ({credentials}) => { +const generateMfaBackupCodes = ({ credentials }) => { return fetch(MFA_BACKUP_CODES_URL, { headers: authHeaders(credentials), method: 'GET' }).then((data) => data.json()) } -const fetchMutes = ({credentials}) => { +const fetchMutes = ({ credentials }) => { return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials }) .then((users) => users.map(parseUser)) } -const muteUser = ({id, credentials}) => { +const muteUser = ({ id, credentials }) => { return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST' }) } -const unmuteUser = ({id, credentials}) => { +const unmuteUser = ({ id, credentials }) => { return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' }) } -const fetchBlocks = ({credentials}) => { +const fetchBlocks = ({ credentials }) => { return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials }) .then((users) => users.map(parseUser)) } -const fetchOAuthTokens = ({credentials}) => { +const fetchOAuthTokens = ({ credentials }) => { const url = '/api/oauth_tokens.json' return fetch(url, { @@ -734,7 +764,7 @@ const fetchOAuthTokens = ({credentials}) => { }) } -const revokeOAuthToken = ({id, credentials}) => { +const revokeOAuthToken = ({ id, credentials }) => { const url = `/api/oauth_tokens/${id}` return fetch(url, { @@ -743,13 +773,13 @@ const revokeOAuthToken = ({id, credentials}) => { }) } -const suggestions = ({credentials}) => { +const suggestions = ({ credentials }) => { return fetch(SUGGESTIONS_URL, { headers: authHeaders(credentials) }).then((data) => data.json()) } -const markNotificationsAsSeen = ({id, credentials}) => { +const markNotificationsAsSeen = ({ id, credentials }) => { const body = new FormData() body.append('latest_id', id) @@ -761,15 +791,39 @@ const markNotificationsAsSeen = ({id, credentials}) => { }).then((data) => data.json()) } -const fetchFavoritedByUsers = ({id}) => { +const vote = ({ pollId, choices, credentials }) => { + const form = new FormData() + form.append('choices', choices) + + return promisedRequest({ + url: MASTODON_VOTE_URL(encodeURIComponent(pollId)), + method: 'POST', + credentials, + payload: { + choices: choices + } + }) +} + +const fetchPoll = ({ pollId, credentials }) => { + return promisedRequest( + { + url: MASTODON_POLL_URL(encodeURIComponent(pollId)), + method: 'GET', + credentials + } + ) +} + +const fetchFavoritedByUsers = ({ id }) => { return promisedRequest({ url: MASTODON_STATUS_FAVORITEDBY_URL(id) }).then((users) => users.map(parseUser)) } -const fetchRebloggedByUsers = ({id}) => { +const fetchRebloggedByUsers = ({ id }) => { return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser)) } -const reportUser = ({credentials, userId, statusIds, comment, forward}) => { +const reportUser = ({ credentials, userId, statusIds, comment, forward }) => { return promisedRequest({ url: MASTODON_REPORT_USER_URL, method: 'POST', @@ -840,6 +894,8 @@ const apiService = { denyUser, suggestions, markNotificationsAsSeen, + vote, + fetchPoll, fetchFavoritedByUsers, fetchRebloggedByUsers, reportUser, diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 8614a0f2..e095d3d2 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -2,57 +2,57 @@ import apiService from '../api/api.service.js' import timelineFetcherService from '../timeline_fetcher/timeline_fetcher.service.js' import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js' -const backendInteractorService = (credentials) => { - const fetchStatus = ({id}) => { - return apiService.fetchStatus({id, credentials}) +const backendInteractorService = credentials => { + const fetchStatus = ({ id }) => { + return apiService.fetchStatus({ id, credentials }) } - const fetchConversation = ({id}) => { - return apiService.fetchConversation({id, credentials}) + const fetchConversation = ({ id }) => { + return apiService.fetchConversation({ id, credentials }) } - const fetchFriends = ({id, maxId, sinceId, limit}) => { - return apiService.fetchFriends({id, maxId, sinceId, limit, credentials}) + const fetchFriends = ({ id, maxId, sinceId, limit }) => { + return apiService.fetchFriends({ id, maxId, sinceId, limit, credentials }) } - const exportFriends = ({id}) => { - return apiService.exportFriends({id, credentials}) + const exportFriends = ({ id }) => { + return apiService.exportFriends({ id, credentials }) } - const fetchFollowers = ({id, maxId, sinceId, limit}) => { - return apiService.fetchFollowers({id, maxId, sinceId, limit, credentials}) + const fetchFollowers = ({ id, maxId, sinceId, limit }) => { + return apiService.fetchFollowers({ id, maxId, sinceId, limit, credentials }) } - const fetchUser = ({id}) => { - return apiService.fetchUser({id, credentials}) + const fetchUser = ({ id }) => { + return apiService.fetchUser({ id, credentials }) } - const fetchUserRelationship = ({id}) => { - return apiService.fetchUserRelationship({id, credentials}) + const fetchUserRelationship = ({ id }) => { + return apiService.fetchUserRelationship({ id, credentials }) } const followUser = (id) => { - return apiService.followUser({credentials, id}) + return apiService.followUser({ credentials, id }) } const unfollowUser = (id) => { - return apiService.unfollowUser({credentials, id}) + return apiService.unfollowUser({ credentials, id }) } const blockUser = (id) => { - return apiService.blockUser({credentials, id}) + return apiService.blockUser({ credentials, id }) } const unblockUser = (id) => { - return apiService.unblockUser({credentials, id}) + return apiService.unblockUser({ credentials, id }) } const approveUser = (id) => { - return apiService.approveUser({credentials, id}) + return apiService.approveUser({ credentials, id }) } const denyUser = (id) => { - return apiService.denyUser({credentials, id}) + return apiService.denyUser({ credentials, id }) } const startFetchingTimeline = ({ timeline, store, userId = false, tag }) => { @@ -63,73 +63,83 @@ const backendInteractorService = (credentials) => { return notificationsFetcher.startFetching({ store, credentials }) } - const tagUser = ({screen_name}, tag) => { - return apiService.tagUser({screen_name, tag, credentials}) + const tagUser = ({ screen_name }, tag) => { + return apiService.tagUser({ screen_name, tag, credentials }) } - const untagUser = ({screen_name}, tag) => { - return apiService.untagUser({screen_name, tag, credentials}) + const untagUser = ({ screen_name }, tag) => { + return apiService.untagUser({ screen_name, tag, credentials }) } - const addRight = ({screen_name}, right) => { - return apiService.addRight({screen_name, right, credentials}) + const addRight = ({ screen_name }, right) => { + return apiService.addRight({ screen_name, right, credentials }) } - const deleteRight = ({screen_name}, right) => { - return apiService.deleteRight({screen_name, right, credentials}) + const deleteRight = ({ screen_name }, right) => { + return apiService.deleteRight({ screen_name, right, credentials }) } - const setActivationStatus = ({screen_name}, status) => { - return apiService.setActivationStatus({screen_name, status, credentials}) + const setActivationStatus = ({ screen_name }, status) => { + return apiService.setActivationStatus({ screen_name, status, credentials }) } - const deleteUser = ({screen_name}) => { - return apiService.deleteUser({screen_name, credentials}) + const deleteUser = ({ screen_name }) => { + return apiService.deleteUser({ screen_name, credentials }) } - const updateNotificationSettings = ({settings}) => { - return apiService.updateNotificationSettings({credentials, settings}) + const vote = (pollId, choices) => { + return apiService.vote({ credentials, pollId, choices }) } - const fetchMutes = () => apiService.fetchMutes({credentials}) - const muteUser = (id) => apiService.muteUser({credentials, id}) - const unmuteUser = (id) => apiService.unmuteUser({credentials, id}) - const fetchBlocks = () => apiService.fetchBlocks({credentials}) - const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials}) - const fetchOAuthTokens = () => apiService.fetchOAuthTokens({credentials}) - const revokeOAuthToken = (id) => apiService.revokeOAuthToken({id, credentials}) - const fetchPinnedStatuses = (id) => apiService.fetchPinnedStatuses({credentials, id}) - const pinOwnStatus = (id) => apiService.pinOwnStatus({credentials, id}) - const unpinOwnStatus = (id) => apiService.unpinOwnStatus({credentials, id}) + const fetchPoll = (pollId) => { + return apiService.fetchPoll({ credentials, pollId }) + } + + const updateNotificationSettings = ({ settings }) => { + return apiService.updateNotificationSettings({ credentials, settings }) + } + + const fetchMutes = () => apiService.fetchMutes({ credentials }) + const muteUser = (id) => apiService.muteUser({ credentials, id }) + const unmuteUser = (id) => apiService.unmuteUser({ credentials, id }) + const fetchBlocks = () => apiService.fetchBlocks({ credentials }) + const fetchFollowRequests = () => apiService.fetchFollowRequests({ credentials }) + const fetchOAuthTokens = () => apiService.fetchOAuthTokens({ credentials }) + const revokeOAuthToken = (id) => apiService.revokeOAuthToken({ id, credentials }) + const fetchPinnedStatuses = (id) => apiService.fetchPinnedStatuses({ credentials, id }) + const pinOwnStatus = (id) => apiService.pinOwnStatus({ credentials, id }) + const unpinOwnStatus = (id) => apiService.unpinOwnStatus({ credentials, id }) const getCaptcha = () => apiService.getCaptcha() const register = (params) => apiService.register({ credentials, params }) - const updateAvatar = ({avatar}) => apiService.updateAvatar({credentials, avatar}) + const updateAvatar = ({ avatar }) => apiService.updateAvatar({ credentials, avatar }) const updateBg = ({ background }) => apiService.updateBg({ credentials, background }) - const updateBanner = ({banner}) => apiService.updateBanner({credentials, banner}) - const updateProfile = ({params}) => apiService.updateProfile({credentials, params}) + const updateBanner = ({ banner }) => apiService.updateBanner({ credentials, banner }) + const updateProfile = ({ params }) => apiService.updateProfile({ credentials, params }) + + const externalProfile = (profileUrl) => apiService.externalProfile({ profileUrl, credentials }) - const externalProfile = (profileUrl) => apiService.externalProfile({profileUrl, credentials}) - const importBlocks = (file) => apiService.importBlocks({file, credentials}) - const importFollows = (file) => apiService.importFollows({file, credentials}) + const importBlocks = (file) => apiService.importBlocks({ file, credentials }) + const importFollows = (file) => apiService.importFollows({ file, credentials }) - const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password}) - const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation}) + const deleteAccount = ({ password }) => apiService.deleteAccount({ credentials, password }) + const changePassword = ({ password, newPassword, newPasswordConfirmation }) => + apiService.changePassword({ credentials, password, newPassword, newPasswordConfirmation }) - const fetchSettingsMFA = () => apiService.settingsMFA({credentials}) - const generateMfaBackupCodes = () => apiService.generateMfaBackupCodes({credentials}) - const mfaSetupOTP = () => apiService.mfaSetupOTP({credentials}) - const mfaConfirmOTP = ({password, token}) => apiService.mfaConfirmOTP({credentials, password, token}) - const mfaDisableOTP = ({password}) => apiService.mfaDisableOTP({credentials, password}) + const fetchSettingsMFA = () => apiService.settingsMFA({ credentials }) + const generateMfaBackupCodes = () => apiService.generateMfaBackupCodes({ credentials }) + const mfaSetupOTP = () => apiService.mfaSetupOTP({ credentials }) + const mfaConfirmOTP = ({ password, token }) => apiService.mfaConfirmOTP({ credentials, password, token }) + const mfaDisableOTP = ({ password }) => apiService.mfaDisableOTP({ credentials, password }) - const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id}) - const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id}) - const reportUser = (params) => apiService.reportUser({credentials, ...params}) + const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({ id }) + const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({ id }) + const reportUser = (params) => apiService.reportUser({ credentials, ...params }) - const favorite = (id) => apiService.favorite({id, credentials}) - const unfavorite = (id) => apiService.unfavorite({id, credentials}) - const retweet = (id) => apiService.retweet({id, credentials}) - const unretweet = (id) => apiService.unretweet({id, credentials}) + const favorite = (id) => apiService.favorite({ id, credentials }) + const unfavorite = (id) => apiService.unfavorite({ id, credentials }) + const retweet = (id) => apiService.retweet({ id, credentials }) + const unretweet = (id) => apiService.unretweet({ id, credentials }) const backendInteractorServiceInstance = { fetchStatus, @@ -180,6 +190,8 @@ const backendInteractorService = (credentials) => { fetchFollowRequests, approveUser, denyUser, + vote, + fetchPoll, fetchFavoritedByUsers, fetchRebloggedByUsers, reportUser, diff --git a/src/services/date_utils/date_utils.js b/src/services/date_utils/date_utils.js new file mode 100644 index 00000000..32e13bca --- /dev/null +++ b/src/services/date_utils/date_utils.js @@ -0,0 +1,45 @@ +export const SECOND = 1000 +export const MINUTE = 60 * SECOND +export const HOUR = 60 * MINUTE +export const DAY = 24 * HOUR +export const WEEK = 7 * DAY +export const MONTH = 30 * DAY +export const YEAR = 365.25 * DAY + +export const relativeTime = (date, nowThreshold = 1) => { + if (typeof date === 'string') date = Date.parse(date) + const round = Date.now() > date ? Math.floor : Math.ceil + const d = Math.abs(Date.now() - date) + let r = { num: round(d / YEAR), key: 'time.years' } + if (d < nowThreshold * SECOND) { + r.num = 0 + r.key = 'time.now' + } else if (d < MINUTE) { + r.num = round(d / SECOND) + r.key = 'time.seconds' + } else if (d < HOUR) { + r.num = round(d / MINUTE) + r.key = 'time.minutes' + } else if (d < DAY) { + r.num = round(d / HOUR) + r.key = 'time.hours' + } else if (d < WEEK) { + r.num = round(d / DAY) + r.key = 'time.days' + } else if (d < MONTH) { + r.num = round(d / WEEK) + r.key = 'time.weeks' + } else if (d < YEAR) { + r.num = round(d / MONTH) + r.key = 'time.months' + } + // Remove plural form when singular + if (r.num === 1) r.key = r.key.slice(0, -1) + return r +} + +export const relativeTimeShort = (date, nowThreshold = 1) => { + const r = relativeTime(date, nowThreshold) + r.key += '_short' + return r +} diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index cdce1538..9af71e4f 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -234,6 +234,7 @@ export const parseStatus = (data) => { output.summary_html = addEmojis(data.spoiler_text, data.emojis) output.external_url = data.url + output.poll = data.poll output.pinned = data.pinned } else { output.favorited = data.favorited diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index e70b0f26..1efecf90 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -1,10 +1,19 @@ import { map } from 'lodash' import apiService from '../api/api.service.js' -const postStatus = ({ store, status, spoilerText, visibility, sensitive, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => { +const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => { const mediaIds = map(media, 'id') - return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType}) + return apiService.postStatus({ + credentials: store.state.users.currentUser.credentials, + status, + spoilerText, + visibility, + sensitive, + mediaIds, + inReplyToStatusId, + contentType, + poll}) .then((data) => { if (!data.error) { store.dispatch('addNewStatuses', { diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index d0b6ccbf..d73106b8 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -202,6 +202,7 @@ const generateColors = (input) => { colors.topBarLink = col.topBarLink || getTextColor(colors.topBar, colors.fgLink) colors.faintLink = col.faintLink || Object.assign({}, col.link) + colors.linkBg = alphaBlend(colors.link, 0.4, colors.bg) colors.icon = mixrgb(colors.bg, colors.text) |
