aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.scss47
-rw-r--r--src/boot/after_store.js3
-rw-r--r--src/components/media_upload/media_upload.vue2
-rw-r--r--src/components/notification/notification.js6
-rw-r--r--src/components/notification/notification.vue4
-rw-r--r--src/components/poll/poll.js107
-rw-r--r--src/components/poll/poll.vue117
-rw-r--r--src/components/poll/poll_form.js121
-rw-r--r--src/components/poll/poll_form.vue133
-rw-r--r--src/components/post_status_form/post_status_form.js44
-rw-r--r--src/components/post_status_form/post_status_form.vue91
-rw-r--r--src/components/settings/settings.vue2
-rw-r--r--src/components/status/status.js12
-rw-r--r--src/components/status/status.vue6
-rw-r--r--src/components/timeago/timeago.vue48
-rw-r--r--src/i18n/ca.json34
-rw-r--r--src/i18n/cs.json34
-rw-r--r--src/i18n/en.json48
-rw-r--r--src/i18n/fi.json60
-rw-r--r--src/i18n/ga.json34
-rw-r--r--src/i18n/ja.json34
-rw-r--r--src/i18n/ja_pedantic.json34
-rw-r--r--src/i18n/oc.json34
-rw-r--r--src/main.js9
-rw-r--r--src/modules/api.js2
-rw-r--r--src/modules/instance.js10
-rw-r--r--src/modules/statuses.js16
-rw-r--r--src/services/api/api.service.js184
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js142
-rw-r--r--src/services/date_utils/date_utils.js45
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js1
-rw-r--r--src/services/status_poster/status_poster.service.js13
-rw-r--r--src/services/style_setter/style_setter.js1
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") }}&nbsp;·&nbsp;
+ </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)